[CLAUDE] PurchaseEvaluation: ngan sach goi thau theo Excel anh Kiet - bang tong hop 2 block + nhap theo role PRO/CCM + xoa module Budget cu (Mig 50)
All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 4m31s

- Mig 50 ReplaceBudgetModuleWithPeWorkItemBudgets: bang moi PeWorkItemBudgets (1 record/cap Du an x Hang muc, UNIQUE filtered [IsDeleted]=0) + drop 5 bang Budget cu + PE/Contracts drop BudgetId + backfill BudgetManualAmount->BudgetPeriodAmount TRUOC DropColumn (phieu UAT giu so) + DELETE menu/permission Bg_* IN-list children-first
- BE: PUT {id}/budget/pro (role Procurement) + {id}/budget/ccm (role CostControl, Adjustment cho phep AM) fail-closed Forbidden-truoc-side-effect + EnsureTrackedAsync race-safe (catch unique -> re-fetch winner, loi khac rethrow) + auto-create record khi tao phieu + budgetSummary DTO (luy ke trinh-truoc/chon-thau-truoc/de-xuat-ky-nay + full fallback du-tru-PRO + canEdit flags) + submit-guard (3) doi predicate BudgetPeriodAmount -> "chua nhap Ngan sach ky nay" + PATCH budget-adjust absolute-set 2 field moi + Contract GIU BudgetManual* (HD nhap tay khong doi) + ke thua HD map BudgetPeriodAmount
- FE x2 app SHA256 identical: bang "TONG HOP NGAN SACH TRINH KY" block A (full dam + ban hanh + V0 hieu chinh + du tru PRO + ghi chu, editable theo canEditPro/canEditCcm) + block B 9 dong cong thuc Excel (5=1+3, 6=2+4, 7=full-5, 8 tu nhap default 7, 9=4+8) + to mau vuot ngan sach #C00000 / am do / red-soft row8>row7 + "Chua chon" khi count=0 + banner phieu chua gan Hang muc + o "Ngan sach ky nay" o create/header + XOA pages/components/types budgets + routes + menuKeys + Layout staticMap 4-place
- Tests: +22 PeWorkItemBudgetTests (auto-create x3, ensure/race x2, authz matrix PRO x5 + CCM x3, budgetSummary aggregates x5, adjust x4) - 14 BudgetPolicyTests xoa theo module - 1 test via-BudgetId -> 263 PASS (45 Domain + 218 Infra, 0 fail)
- database-agent advise adopted: khong FK vat ly PE/Contracts->Budgets (DropColumn khong can DropForeignKey) + DropIndex truoc DropColumn (SQL 5074) + IN-list thay LIKE Bg_% (underscore wildcard + miss root) + khong Serializable wrap (nested-tx conflict codegen)
- Reviewer PASS-with-minor 0 blocker (verdict-first survived); 2 minor da sua truoc commit (comment adjustMut absolute-set + dead key budgetId); note: F4 approver-edit-budget UI entry tam drafter-only, BE van cho approver scope - cho UAT anh Kiet
- Scaffold-bug caught: EF tu sinh RenameColumn BudgetManualAmount->ExpectedRemainingAmount (SAI semantics) -> thay bang Add+UPDATE+Drop

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
pqhuy1987
2026-06-13 01:07:27 +07:00
parent 6db195dd42
commit 79ef8da9f4
70 changed files with 9052 additions and 5956 deletions

View File

@ -24,8 +24,6 @@ import { UsersPage } from '@/pages/system/UsersPage'
import { PurchaseEvaluationsListPage, PurchaseEvaluationDetailPage } from '@/pages/pe/PurchaseEvaluationsListPage'
import { PurchaseEvaluationCreatePage } from '@/pages/pe/PurchaseEvaluationCreatePage'
import { PurchaseEvaluationWorkspacePage } from '@/pages/pe/PurchaseEvaluationWorkspacePage'
import { BudgetsListPage, BudgetDetailPage } from '@/pages/budgets/BudgetsListPage'
import { BudgetCreatePage } from '@/pages/budgets/BudgetCreatePage'
import { EmployeesListPage } from '@/pages/hrm/EmployeesListPage'
import { EmployeeCreatePage } from '@/pages/hrm/EmployeeCreatePage'
import { HrmConfigsPage } from '@/pages/hrm/HrmConfigsPage'
@ -80,9 +78,6 @@ function App() {
<Route path="/purchase-evaluations/workspace" element={<PurchaseEvaluationWorkspacePage />} />
<Route path="/purchase-evaluations/new" element={<PurchaseEvaluationCreatePage />} />
<Route path="/purchase-evaluations/:id" element={<PurchaseEvaluationDetailPage />} />
<Route path="/budgets" element={<BudgetsListPage />} />
<Route path="/budgets/new" element={<BudgetCreatePage />} />
<Route path="/budgets/:id" element={<BudgetDetailPage />} />
{/* Hồ sơ Nhân sự (Phase 10.1 G-H1 — Mig 34) */}
<Route path="/employees" element={<EmployeesListPage />} />
<Route path="/employees/new" element={<EmployeeCreatePage />} />

View File

@ -47,10 +47,6 @@ function resolvePath(key: string): string | null {
CatalogWorkItems: '/master/catalogs/work-items',
PurchaseEvaluations: '/purchase-evaluations',
PeWorkflows: '/system/pe-workflows',
Budgets: '/budgets',
Bg_List: '/budgets',
Bg_Create: '/budgets/new',
Bg_Pending: '/budgets?phase=Pending',
// [Phase 10.1 G-H1 S33 2026-05-26] Module Hồ sơ Nhân sự (Mig 34). LESSON
// Plan CA Hotfix 1 gotcha #50: PHẢI mirror staticMap khi thêm page mới
// — nếu thiếu, MenuLeaf line ~198 `if (!path) return null` → sidebar drop silent.

View File

@ -1,491 +0,0 @@
// Detail content cho 1 ngân sách. Flat render (no tabs):
// Section 1 = Thông tin Header
// Section 2 = Hạng mục (table CRUD inline)
// Approvals + Changelog → moved sang Panel 3 (BudgetWorkflowPanel).
import { useState } from 'react'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { useNavigate } from 'react-router-dom'
import { toast } from 'sonner'
import { Pencil, Plus, Trash2 } from 'lucide-react'
import { Button } from '@/components/ui/Button'
import { Dialog } from '@/components/ui/Dialog'
import { Input } from '@/components/ui/Input'
import { Label } from '@/components/ui/Label'
import { api } from '@/lib/api'
import { getErrorMessage } from '@/lib/apiError'
import { cn } from '@/lib/cn'
import {
BudgetPhase,
BudgetPhaseColor,
BudgetPhaseLabel,
type BudgetChangelog,
type BudgetDetailBundle,
type BudgetDetailBody,
type BudgetDetailRow,
} from '@/types/budget'
const fmtMoney = (v: number) => v.toLocaleString('vi-VN')
export function BudgetDetailTabs({
budget,
onBack,
onDelete,
readOnly = false,
}: {
budget: BudgetDetailBundle
onBack: () => void
onDelete: () => void
/** Menu "Duyệt" — ẩn mọi action thêm/sửa/xóa, chỉ xem + duyệt phase. */
readOnly?: boolean
}) {
const navigate = useNavigate()
const isDraft = budget.phase === BudgetPhase.DangSoanThao
return (
<div className="rounded-lg border border-slate-200 bg-white shadow-sm">
<div className="flex flex-wrap items-center justify-between gap-2 border-b border-slate-200 px-5 py-3">
<div>
<div className="flex items-center gap-2">
<h2 className="text-base font-semibold text-slate-900">{budget.tenNganSach}</h2>
<span className={cn('rounded px-1.5 py-0.5 text-[11px] font-medium', BudgetPhaseColor[budget.phase])}>
{BudgetPhaseLabel[budget.phase]}
</span>
{readOnly && (
<span className="rounded bg-slate-100 px-1.5 py-0.5 text-[11px] font-medium text-slate-600">
chế đ duyệt
</span>
)}
</div>
<div className="mt-0.5 flex flex-wrap items-center gap-2 text-[12px] text-slate-500">
<span className="font-mono">{budget.maNganSach ?? '—'}</span>
<span>·</span>
<span>Năm {budget.namNganSach}</span>
<span>·</span>
<span>{budget.projectName}</span>
{budget.drafterName && (<><span>·</span><span>Soạn: {budget.drafterName}</span></>)}
</div>
</div>
<div className="flex gap-2">
{isDraft && !readOnly && (
<>
<Button
variant="ghost"
onClick={() => navigate(`/budgets/new?id=${budget.id}`)}
className="gap-1.5 text-xs"
>
<Pencil className="h-3.5 w-3.5" /> Sửa header
</Button>
<Button variant="danger" onClick={onDelete} className="gap-1.5 text-xs">
<Trash2 className="h-3.5 w-3.5" /> Xóa
</Button>
</>
)}
<Button variant="ghost" onClick={onBack} className="text-xs"> Đóng</Button>
</div>
</div>
<div className="divide-y divide-slate-200">
<Section title="Thông tin">
<InfoTab budget={budget} />
</Section>
<Section title={`Hạng mục (${budget.details.length}) — Tổng: ${fmtMoney(budget.tongNganSach)} đ`}>
<ItemsTab budget={budget} readOnly={readOnly} />
</Section>
</div>
</div>
)
}
function Section({ title, children }: { title: string; children: React.ReactNode }) {
return (
<section className="px-5 py-4">
<h3 className="mb-3 text-xs font-semibold uppercase tracking-wide text-slate-500">{title}</h3>
{children}
</section>
)
}
// ===== Exports cho Panel 3 — Approvals history + Changelog =====
export function BudgetApprovalsSection({ budget }: { budget: BudgetDetailBundle }) {
return (
<div>
<h3 className="mb-2 text-sm font-semibold text-slate-900">
Lịch sử duyệt ({budget.approvals.length})
</h3>
<ApprovalsList budget={budget} />
</div>
)
}
export function BudgetHistorySection({ budget }: { budget: BudgetDetailBundle }) {
return (
<div>
<h3 className="mb-2 text-sm font-semibold text-slate-900">Lịch sử thay đi</h3>
<HistoryList budget={budget} />
</div>
)
}
// ===== Section: Thông tin Header =====
function InfoTab({ budget }: { budget: BudgetDetailBundle }) {
return (
<dl className="grid grid-cols-2 gap-x-6 gap-y-3 text-sm">
<Field label="Tên ngân sách" value={budget.tenNganSach} />
<Field label="Mã ngân sách" value={<span className="font-mono">{budget.maNganSach ?? '—'}</span>} />
<Field label="Năm ngân sách" value={budget.namNganSach} />
<Field label="Dự án" value={budget.projectName} />
<Field label="Phòng ban" value={budget.departmentName ?? '—'} />
<Field label="Người soạn" value={budget.drafterName ?? '—'} />
<Field label="Tổng ngân sách" value={<span className="font-semibold text-brand-700">{fmtMoney(budget.tongNganSach)} đ</span>} />
<Field label="Trạng thái" value={<span className={cn('rounded px-1.5 py-0.5 text-[11px]', BudgetPhaseColor[budget.phase])}>{BudgetPhaseLabel[budget.phase]}</span>} />
{budget.description && (
<div className="col-span-2">
<dt className="text-[11px] uppercase tracking-wide text-slate-400"> tả</dt>
<dd className="mt-0.5 whitespace-pre-wrap text-slate-800">{budget.description}</dd>
</div>
)}
</dl>
)
}
function Field({ label, value }: { label: string; value: React.ReactNode }) {
return (
<div>
<dt className="text-[11px] uppercase tracking-wide text-slate-400">{label}</dt>
<dd className="mt-0.5 text-slate-800">{value}</dd>
</div>
)
}
// ===== Section: Hạng mục table CRUD =====
function ItemsTab({ budget, readOnly = false }: { budget: BudgetDetailBundle; readOnly?: boolean }) {
const qc = useQueryClient()
const [open, setOpen] = useState(false)
const [editRow, setEditRow] = useState<BudgetDetailRow | null>(null)
const isDraft = budget.phase === BudgetPhase.DangSoanThao
const canMutate = !readOnly && isDraft
const remove = useMutation({
mutationFn: async (rowId: string) => api.delete(`/budgets/${budget.id}/details/${rowId}`),
onSuccess: () => {
toast.success('Đã xóa hạng mục.')
qc.invalidateQueries({ queryKey: ['budget-detail', budget.id] })
qc.invalidateQueries({ queryKey: ['budget-list'] })
},
onError: e => toast.error(getErrorMessage(e)),
})
return (
<div>
{canMutate && (
<div className="mb-3 flex justify-end">
<Button onClick={() => setOpen(true)} className="gap-1.5 text-xs">
<Plus className="h-3.5 w-3.5" /> Thêm hạng mục
</Button>
</div>
)}
{budget.details.length === 0 ? (
<p className="text-sm text-slate-500">
{canMutate ? 'Chưa có hạng mục nào. Thêm để bắt đầu lập ngân sách.' : 'Chưa có hạng mục.'}
</p>
) : (
<div className="overflow-x-auto">
<table className="min-w-full text-sm">
<thead className="bg-slate-50 text-xs uppercase text-slate-500">
<tr>
<th className="px-2 py-2 text-left">#</th>
<th className="px-2 py-2 text-left">Nhóm</th>
<th className="px-2 py-2 text-left"> / Nội dung</th>
<th className="px-2 py-2 text-left">ĐVT</th>
<th className="px-2 py-2 text-right">KL</th>
<th className="px-2 py-2 text-right">Đơn giá</th>
<th className="px-2 py-2 text-right">Thành tiền</th>
{canMutate && <th className="px-2 py-2"></th>}
</tr>
</thead>
<tbody className="divide-y divide-slate-100">
{budget.details.map((d, idx) => (
<tr key={d.id} className="align-top">
<td className="px-2 py-2 text-slate-400">{idx + 1}</td>
<td className="px-2 py-2">
<div className="font-mono text-[11px] text-slate-500">{d.groupCode}</div>
<div className="text-[12px] text-slate-700">{d.groupName}</div>
</td>
<td className="px-2 py-2 max-w-md">
{d.itemCode && <div className="font-mono text-[11px] text-slate-500">{d.itemCode}</div>}
<div className="text-slate-800">{d.noiDung}</div>
{d.ghiChu && <div className="mt-0.5 text-[11px] italic text-slate-500">{d.ghiChu}</div>}
</td>
<td className="px-2 py-2 text-slate-600">{d.donViTinh ?? '—'}</td>
<td className="px-2 py-2 text-right tabular-nums">{fmtMoney(d.khoiLuong)}</td>
<td className="px-2 py-2 text-right tabular-nums">{fmtMoney(d.donGia)}</td>
<td className="px-2 py-2 text-right font-medium tabular-nums text-slate-900">
{fmtMoney(d.thanhTien)}
</td>
{canMutate && (
<td className="px-2 py-2 text-right">
<div className="flex justify-end gap-1">
<button
onClick={() => setEditRow(d)}
className="rounded p-1 text-slate-400 hover:bg-slate-100 hover:text-slate-700"
title="Sửa"
>
<Pencil className="h-3.5 w-3.5" />
</button>
<button
onClick={() => {
if (confirm('Xóa hạng mục này?')) remove.mutate(d.id)
}}
className="rounded p-1 text-slate-400 hover:bg-red-50 hover:text-red-600"
title="Xóa"
>
<Trash2 className="h-3.5 w-3.5" />
</button>
</div>
</td>
)}
</tr>
))}
</tbody>
<tfoot>
<tr className="border-t-2 border-slate-200 bg-slate-50 font-semibold">
<td colSpan={6} className="px-2 py-2 text-right text-slate-600">Tổng:</td>
<td className="px-2 py-2 text-right tabular-nums text-brand-700">
{fmtMoney(budget.tongNganSach)}
</td>
{canMutate && <td></td>}
</tr>
</tfoot>
</table>
</div>
)}
{open && (
<DetailRowDialog
budgetId={budget.id}
onClose={() => setOpen(false)}
/>
)}
{editRow && (
<DetailRowDialog
budgetId={budget.id}
existing={editRow}
onClose={() => setEditRow(null)}
/>
)}
</div>
)
}
// ===== Dialog: Thêm / Sửa hạng mục =====
function DetailRowDialog({
budgetId,
existing,
onClose,
}: {
budgetId: string
existing?: BudgetDetailRow
onClose: () => void
}) {
const qc = useQueryClient()
const [form, setForm] = useState<BudgetDetailBody>({
groupCode: existing?.groupCode ?? '',
groupName: existing?.groupName ?? '',
itemCode: existing?.itemCode ?? null,
noiDung: existing?.noiDung ?? '',
donViTinh: existing?.donViTinh ?? null,
khoiLuong: existing?.khoiLuong ?? 0,
donGia: existing?.donGia ?? 0,
thanhTien: existing?.thanhTien ?? 0,
ghiChu: existing?.ghiChu ?? null,
})
// Auto-compute thành tiền khi đổi KL/đơn giá (UX nicety)
function setQty(v: number) {
const next = { ...form, khoiLuong: v, thanhTien: v * form.donGia }
setForm(next)
}
function setPrice(v: number) {
const next = { ...form, donGia: v, thanhTien: form.khoiLuong * v }
setForm(next)
}
const save = useMutation({
mutationFn: async () => {
const payload = {
groupCode: form.groupCode,
groupName: form.groupName,
itemCode: form.itemCode || null,
noiDung: form.noiDung,
donViTinh: form.donViTinh || null,
khoiLuong: form.khoiLuong,
donGia: form.donGia,
thanhTien: form.thanhTien,
ghiChu: form.ghiChu || null,
}
if (existing) {
return api.put(`/budgets/${budgetId}/details/${existing.id}`, payload)
}
return api.post(`/budgets/${budgetId}/details`, payload)
},
onSuccess: () => {
toast.success(existing ? 'Đã cập nhật hạng mục.' : 'Đã thêm hạng mục.')
qc.invalidateQueries({ queryKey: ['budget-detail', budgetId] })
qc.invalidateQueries({ queryKey: ['budget-list'] })
onClose()
},
onError: e => toast.error(getErrorMessage(e)),
})
return (
<Dialog
open
onClose={onClose}
title={existing ? 'Sửa hạng mục' : 'Thêm hạng mục'}
footer={<>
<Button variant="ghost" onClick={onClose}>Hủy</Button>
<Button
onClick={() => save.mutate()}
disabled={!form.groupCode || !form.groupName || !form.noiDung || save.isPending}
>
{existing ? 'Lưu' : 'Thêm'}
</Button>
</>}
>
<div className="space-y-3">
<div className="grid grid-cols-2 gap-3">
<div>
<Label> nhóm *</Label>
<Input
value={form.groupCode}
onChange={e => setForm({ ...form, groupCode: e.target.value })}
placeholder="A.I"
/>
</div>
<div>
<Label>Tên nhóm *</Label>
<Input
value={form.groupName}
onChange={e => setForm({ ...form, groupName: e.target.value })}
placeholder="Vật tư xây dựng"
/>
</div>
</div>
<div>
<Label> hạng mục</Label>
<Input
value={form.itemCode ?? ''}
onChange={e => setForm({ ...form, itemCode: e.target.value || null })}
/>
</div>
<div>
<Label>Nội dung *</Label>
<Input
value={form.noiDung}
onChange={e => setForm({ ...form, noiDung: e.target.value })}
placeholder="Bê tông M250"
/>
</div>
<div className="grid grid-cols-4 gap-3">
<div>
<Label>ĐVT</Label>
<Input
value={form.donViTinh ?? ''}
onChange={e => setForm({ ...form, donViTinh: e.target.value || null })}
placeholder="m³"
/>
</div>
<div>
<Label>Khối lượng</Label>
<Input
type="number"
step="0.0001"
value={form.khoiLuong}
onChange={e => setQty(Number(e.target.value))}
/>
</div>
<div>
<Label>Đơn giá</Label>
<Input
type="number"
step="0.01"
value={form.donGia}
onChange={e => setPrice(Number(e.target.value))}
/>
</div>
<div>
<Label>Thành tiền</Label>
<Input
type="number"
step="0.01"
value={form.thanhTien}
onChange={e => setForm({ ...form, thanhTien: Number(e.target.value) })}
/>
</div>
</div>
<div>
<Label>Ghi chú</Label>
<Input
value={form.ghiChu ?? ''}
onChange={e => setForm({ ...form, ghiChu: e.target.value || null })}
/>
</div>
</div>
</Dialog>
)
}
// ===== Sub: Approvals list =====
function ApprovalsList({ budget }: { budget: BudgetDetailBundle }) {
if (budget.approvals.length === 0)
return <p className="text-sm text-slate-500">Chưa bước duyệt nào.</p>
return (
<ol className="space-y-2">
{budget.approvals.map(a => (
<li key={a.id} className="rounded border border-slate-200 bg-white p-3 text-sm">
<div className="flex items-center justify-between">
<div>
<span className={cn('rounded px-1.5 py-0.5 text-[11px]', BudgetPhaseColor[a.fromPhase])}>
{BudgetPhaseLabel[a.fromPhase]}
</span>
<span className="mx-2"></span>
<span className={cn('rounded px-1.5 py-0.5 text-[11px]', BudgetPhaseColor[a.toPhase])}>
{BudgetPhaseLabel[a.toPhase]}
</span>
</div>
<span className="text-xs text-slate-500">{new Date(a.approvedAt).toLocaleString('vi-VN')}</span>
</div>
<div className="mt-1 text-xs text-slate-500">
{a.approverName ?? 'Hệ thống'}{a.comment && ` · ${a.comment}`}
</div>
</li>
))}
</ol>
)
}
// ===== Sub: Changelog list =====
function HistoryList({ budget }: { budget: BudgetDetailBundle }) {
const logs = useQuery({
queryKey: ['budget-changelog', budget.id],
queryFn: async () => (await api.get<BudgetChangelog[]>(`/budgets/${budget.id}/changelogs`)).data,
})
if (logs.isLoading) return <p className="text-sm text-slate-500">Đang tải</p>
if (!logs.data || logs.data.length === 0)
return <p className="text-sm text-slate-500">Chưa lịch sử.</p>
return (
<ol className="space-y-1.5 text-sm">
{logs.data.map(l => (
<li key={l.id} className="border-l-2 border-slate-200 py-1 pl-3">
<div className="flex items-center justify-between text-xs text-slate-500">
<span>{l.userName ?? 'Hệ thống'}</span>
<span>{new Date(l.createdAt).toLocaleString('vi-VN')}</span>
</div>
<div className="text-slate-800">{l.summary}</div>
{l.contextNote && <div className="text-xs text-slate-500">{l.contextNote}</div>}
</li>
))}
</ol>
)
}

View File

@ -1,225 +0,0 @@
// Panel 3 — workflow timeline + transition buttons + approval history + changelog.
// Pulls nextPhases từ BE bundle (single source of truth).
import { useState } from 'react'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { toast } from 'sonner'
import { Dialog } from '@/components/ui/Dialog'
import { Button } from '@/components/ui/Button'
import { Label } from '@/components/ui/Label'
import { Textarea } from '@/components/ui/Textarea'
import { api } from '@/lib/api'
import { getErrorMessage } from '@/lib/apiError'
import { cn } from '@/lib/cn'
import {
ApprovalDecision,
ApprovalStage,
BudgetPhase,
BudgetPhaseColor,
BudgetPhaseLabel,
type BudgetDepartmentApproval,
type BudgetDetailBundle,
} from '@/types/budget'
import { BudgetApprovalsSection, BudgetHistorySection } from './BudgetDetailTabs'
export function BudgetWorkflowPanel({ budget }: { budget: BudgetDetailBundle }) {
const [target, setTarget] = useState<number | null>(null)
const [comment, setComment] = useState('')
const qc = useQueryClient()
// 2-stage dept approvals (Migration 16) — fetch riêng để FE render timeline.
const { data: deptApprovals = [] } = useQuery<BudgetDepartmentApproval[]>({
queryKey: ['budget-dept-approvals', budget.id],
queryFn: async () => (await api.get(`/budgets/${budget.id}/department-approvals`)).data,
})
const transition = useMutation({
mutationFn: async () =>
api.post(`/budgets/${budget.id}/transitions`, {
targetPhase: target,
decision: target === BudgetPhase.TuChoi ? ApprovalDecision.Reject : ApprovalDecision.Approve,
comment: comment || null,
}),
onSuccess: () => {
toast.success('Đã chuyển phase.')
qc.invalidateQueries({ queryKey: ['budget-detail', budget.id] })
qc.invalidateQueries({ queryKey: ['budget-list'] })
qc.invalidateQueries({ queryKey: ['budget-changelog', budget.id] })
qc.invalidateQueries({ queryKey: ['budget-dept-approvals', budget.id] })
setTarget(null)
setComment('')
},
onError: e => toast.error(getErrorMessage(e)),
})
const next = budget.workflow.nextPhases
return (
<div className="space-y-4">
<div>
<h3 className="text-sm font-semibold text-slate-900">Quy trình</h3>
<p className="mt-0.5 text-[11px] text-slate-500">{budget.workflow.policyDescription}</p>
</div>
<ol className="space-y-1.5">
{budget.workflow.activePhases
.filter(p => p !== BudgetPhase.TuChoi)
.map(p => {
const isCurrent = budget.phase === p
const isPast = isPastPhase(budget.phase, p, budget.workflow.activePhases)
return (
<li key={p}>
<div
className={cn(
'flex items-center gap-2 rounded border px-2 py-1.5 text-xs',
isCurrent && 'border-brand-300 bg-brand-50 font-medium',
isPast && 'border-emerald-200 bg-emerald-50 text-emerald-700',
!isCurrent && !isPast && 'border-slate-200 text-slate-500',
)}
>
<span className={cn('rounded px-1.5 py-0.5 text-[10px]', BudgetPhaseColor[p])}>{p}</span>
<span className="truncate">{BudgetPhaseLabel[p]}</span>
{isCurrent && <span className="ml-auto text-[10px] text-brand-700"> hiện tại</span>}
{isPast && <span className="ml-auto text-[10px] text-emerald-600"></span>}
</div>
</li>
)
})}
</ol>
{next.length > 0 && (
<div>
<Label className="text-xs">Chuyển tiếp:</Label>
<div className="mt-1 flex flex-wrap gap-1.5">
{next.map(p => (
<button
key={p}
onClick={() => setTarget(p)}
className={cn(
'rounded border px-2 py-1 text-[11px] transition',
p === BudgetPhase.TuChoi
? 'border-red-200 text-red-700 hover:bg-red-50'
: p === BudgetPhase.DangSoanThao
? 'border-amber-300 text-amber-700 hover:bg-amber-50'
: 'border-brand-300 text-brand-700 hover:bg-brand-50',
)}
>
{BudgetPhaseLabel[p]}
</button>
))}
</div>
</div>
)}
{target !== null && (
<Dialog
open
onClose={() => setTarget(null)}
title={`Chuyển → ${BudgetPhaseLabel[target]}`}
footer={<>
<Button variant="ghost" onClick={() => setTarget(null)}>Hủy</Button>
<Button onClick={() => transition.mutate()} disabled={transition.isPending}>Xác nhận</Button>
</>}
>
<Label>Ghi chú (tùy chọn)</Label>
<Textarea value={comment} onChange={e => setComment(e.target.value)} rows={3} />
</Dialog>
)}
{deptApprovals.length > 0 && (
<div className="border-t border-slate-200 pt-4">
<BudgetDeptApprovalsSection rows={deptApprovals} currentPhase={budget.phase} />
</div>
)}
<div className="border-t border-slate-200 pt-4">
<BudgetApprovalsSection budget={budget} />
</div>
<div className="border-t border-slate-200 pt-4">
<BudgetHistorySection budget={budget} />
</div>
</div>
)
}
// 2-stage dept approval timeline (Migration 16) — mirror PE/Contract pattern.
function BudgetDeptApprovalsSection({
rows,
currentPhase,
}: {
rows: BudgetDepartmentApproval[]
currentPhase: number
}) {
const grouped = new Map<number, Map<string, BudgetDepartmentApproval[]>>()
for (const r of rows) {
if (!grouped.has(r.phaseAtApproval)) grouped.set(r.phaseAtApproval, new Map())
const byDept = grouped.get(r.phaseAtApproval)!
if (!byDept.has(r.departmentId)) byDept.set(r.departmentId, [])
byDept.get(r.departmentId)!.push(r)
}
const phaseOrder = [...grouped.keys()].sort((a, b) => a - b)
return (
<div>
<h3 className="text-sm font-semibold text-slate-900">Tiến trình duyệt 2-cấp phòng ban</h3>
<p className="mt-0.5 text-[11px] text-slate-500">NV Review TPB Confirm. Phase chỉ chuyển khi Confirm.</p>
<div className="mt-2 space-y-3">
{phaseOrder.map(phase => {
const byDept = grouped.get(phase)!
return (
<div key={phase}>
<div className={cn(
'inline-block rounded px-1.5 py-0.5 text-[10px] font-medium',
BudgetPhaseColor[phase] ?? 'bg-slate-100 text-slate-700',
)}>
{BudgetPhaseLabel[phase] ?? `Phase ${phase}`}
</div>
<div className="mt-1 space-y-1.5">
{[...byDept.entries()].map(([deptId, stages]) => {
const review = stages.find(s => s.stage === ApprovalStage.Review)
const confirm = stages.find(s => s.stage === ApprovalStage.Confirm)
const deptName = stages[0]?.departmentName ?? '(không rõ phòng)'
const isPending = phase === currentPhase && review && !confirm
return (
<div key={deptId} className={cn(
'rounded border px-2 py-1.5 text-[11px]',
isPending ? 'border-amber-300 bg-amber-50' : 'border-slate-200 bg-slate-50',
)}>
<div className="font-medium text-slate-700">{deptName}</div>
<div className="mt-1 grid grid-cols-[60px_1fr] gap-x-2 gap-y-0.5">
<span className="text-slate-500">Review:</span>
<span className={review ? 'text-slate-700' : 'text-slate-400'}>
{review
? <> {review.approverName} <span className="text-slate-500"> {fmtTime(review.approvedAt)}</span>{review.comment && <span className="text-slate-500"> · "{review.comment}"</span>}</>
: '— chưa có'}
</span>
<span className="text-slate-500">Confirm:</span>
<span className={confirm ? 'text-emerald-700' : 'text-amber-700'}>
{confirm
? <> {confirm.approverName}{confirm.isBypassed && <span className="ml-1 rounded bg-fuchsia-100 px-1 text-[9px] text-fuchsia-700">bypass</span>} <span className="text-slate-500"> {fmtTime(confirm.approvedAt)}</span>{confirm.comment && <span className="text-slate-500"> · "{confirm.comment}"</span>}</>
: '⏳ chờ TPB confirm'}
</span>
</div>
</div>
)
})}
</div>
</div>
)
})}
</div>
</div>
)
}
function fmtTime(iso: string): string {
const d = new Date(iso)
return d.toLocaleString('vi-VN', { day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit' })
}
function isPastPhase(current: number, p: number, active: number[]): boolean {
const orderedIdx = active.indexOf(p)
const currentIdx = active.indexOf(current)
if (orderedIdx < 0 || currentIdx < 0) return false
return orderedIdx < currentIdx && p !== BudgetPhase.TuChoi
}

View File

@ -6,7 +6,7 @@ import { useEffect, useMemo, 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, Download, Eye, Paperclip, Pencil, Plus, Trash2, Upload, Wallet } from 'lucide-react'
import { Check, ChevronDown, ChevronRight, Download, Eye, Paperclip, Pencil, Plus, Trash2, Upload } from 'lucide-react'
import { Button } from '@/components/ui/Button'
import { Dialog } from '@/components/ui/Dialog'
import { Input } from '@/components/ui/Input'
@ -40,9 +40,8 @@ import {
type PeQuote,
type PeSupplier,
} from '@/types/purchaseEvaluation'
import { BudgetPhase, type BudgetListItem } from '@/types/budget'
import { SupplierType, SupplierTypeLabel } from '@/types/master'
import type { Paged, Supplier } from '@/types/master'
import type { Supplier } from '@/types/master'
const fmtMoney = (v: number) => v.toLocaleString('vi-VN')
@ -174,9 +173,10 @@ export function PeDetailTabs({
const gia = computeGiaChaoThau(evaluation)
if (gia == null || gia <= 0) missing.push("Đơn vị được chọn chưa có giá chào thầu")
}
// 3. Chưa nhập Ngân sách (không link Budget entity VÀ không nhập manual amount)
if (evaluation.budgetId == null && (evaluation.budgetManualAmount == null || evaluation.budgetManualAmount <= 0)) {
missing.push("Chưa nhập Ngân sách")
// 3. Chưa nhập Ngân sách kỳ này (S61 — row 3 bảng tổng hợp, drafter nhập).
// Predicate MIRROR BE guard: BudgetPeriodAmount is null || <= 0.
if (evaluation.budgetPeriodAmount == null || evaluation.budgetPeriodAmount <= 0) {
missing.push("Chưa nhập Ngân sách kỳ này")
}
// 4. Chưa đính kèm Bảng so sánh (attachment với supplier-row null — chuẩn Section 3)
if (!evaluation.attachments?.some(a => a.purchaseEvaluationSupplierId === null)) {
@ -286,12 +286,9 @@ 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>
{/* S61 — Section "Điều chỉnh ngân sách" cũ (BudgetAdjustSection) XÓA:
module Budget bỏ hẳn, bảng TỔNG HỢP NGÂN SÁCH TRÌNH KÝ trong Section 3
thay thế (PRO/CCM/drafter nhập trực tiếp theo capability flag BE). */}
</div>
{/* Action bar bottom — workspace mode + canEdit + !readOnly. 3 nút:
@ -680,9 +677,11 @@ function InfoTab({ ev, readOnly, autoEdit }: { ev: PeDetailBundle; readOnly: boo
diaDiem: diaDiem || null,
moTa: moTa || null,
paymentTerms: paymentTerms || null,
budgetId: ev.budgetId,
budgetManualName: ev.budgetManualName,
budgetManualAmount: ev.budgetManualAmount,
// S61 — module Budget cũ XÓA HẲN; PE giữ 2 ô ngân sách mới (echo lại
// giá trị hiện tại để PUT update không xóa nhầm — drafter sửa qua bảng
// TỔNG HỢP NGÂN SÁCH / PATCH budget-adjust).
budgetPeriodAmount: ev.budgetPeriodAmount,
expectedRemainingAmount: ev.expectedRemainingAmount,
})
},
onSuccess: () => {
@ -854,325 +853,434 @@ function NccSelectorRow({ ev, readOnly }: { ev: PeDetailBundle; readOnly: boolea
)
}
// ===== b. Ngân sách inline editor (Mig 17) =====
// Hiển thị + edit budget link / manual fields ngay trong Section 2 — KHÔNG cần
// đi tới "Sửa header" page. Visible trong cả 3 view (Workspace / Danh sách /
// Duyệt). Edit chỉ enable khi !readOnly + phase editable (DangSoanThao /
// TraLai). Read-only khi pendingMe=1 hoặc phase đã gửi duyệt / đã duyệt /
// từ chối. Empty values hiển thị empty (per user 2026-05-07).
function BudgetFieldRow({ ev, readOnly }: { ev: PeDetailBundle; readOnly: boolean }) {
const canEdit = !readOnly && isEditablePhase(ev.phase)
const qc = useQueryClient()
// ===== b. TỔNG HỢP NGÂN SÁCH TRÌNH KÝ (S61 — Excel anh Kiệt) =====
// Module Budget cũ XÓA HẲN → ngân sách gói thầu per (Dự án × Hạng mục) compute
// BE trả `ev.budgetSummary`. 2 block:
// A. NGÂN SÁCH (gói thầu): full / ban hành lần đầu (CCM) / hiệu chỉnh (CCM) /
// dự trù PRO + ghi chú (PRO) — editable theo capability flag canEditCcm/canEditPro.
// B. THỰC HIỆN: 9 dòng công thức Excel — drafter nhập row3 (NS kỳ này) + row8
// (giá trị thực hiện dự kiến còn lại) qua PATCH /budget-adjust.
// budgetSummary=null → phiếu cũ chưa gắn Hạng mục → banner nhắc gắn.
// Detect mode khi mount/refresh: prefer manual mode nếu đã có data manual + ko link.
// Session 20 turn 6: user yêu cầu manual mode chỉ nhập số tiền — bỏ Tên field
// khỏi UI. State manualName drop, BE save luôn null cho field này. Data cũ với
// tên vẫn hiển thị OK ở read-only display (ev.budgetManualName).
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)
// fmtVnd: "1.234.567 đ". fmtPct: 1 chữ số thập phân, guard chia-0 (denom<=0 → null).
const fmtVnd = (v: number) => `${Math.round(v).toLocaleString('vi-VN')} đ`
const fmtVndSigned = (v: number) =>
v < 0 ? `(${Math.round(Math.abs(v)).toLocaleString('vi-VN')}) đ` : `${Math.round(v).toLocaleString('vi-VN')} đ`
const fmtPct = (num: number, denom: number): string | null =>
denom > 0 ? `${((num / denom) * 100).toFixed(1)}%` : null
// Eligible budgets — chỉ fetch khi user có khả năng edit
const eligibleBudgets = useQuery({
queryKey: ['eligible-budgets', ev.projectId],
queryFn: async () => (await api.get<Paged<BudgetListItem>>('/budgets', {
params: { pageSize: 100, projectId: ev.projectId, phase: BudgetPhase.DaDuyet },
})).data.items,
enabled: canEdit,
})
// Dirty detect — compare current state vs ev original
const dirty = manualMode !== initialManual
|| (manualMode && manualAmount !== (ev.budgetManualAmount ?? 0))
|| (!manualMode && budgetId !== (ev.budgetId ?? ''))
const save = useMutation({
mutationFn: async () => {
const payload = manualMode
? { budgetId: null, budgetManualName: null, budgetManualAmount: manualAmount > 0 ? manualAmount : null }
: { budgetId: budgetId || null, budgetManualName: null, budgetManualAmount: null }
await api.put(`/purchase-evaluations/${ev.id}`, {
id: ev.id,
tenGoiThau: ev.tenGoiThau,
diaDiem: ev.diaDiem,
moTa: ev.moTa,
paymentTerms: ev.paymentTerms,
...payload,
})
},
onSuccess: () => {
toast.success('Đã cập nhật ngân sách')
qc.invalidateQueries({ queryKey: ['pe-detail', ev.id] })
qc.invalidateQueries({ queryKey: ['pe-list'] })
},
onError: e => toast.error(getErrorMessage(e)),
})
// Read-only mode: chỉ display (không toggle, không edit)
if (!canEdit) {
return (
<FormRow
label="b. Ngân sách"
value={ev.budget ? (
<a href={`/budgets?id=${ev.budget.id}`} className="text-brand-600 hover:underline">
<span className="font-mono text-[11px]">{ev.budget.maNganSach ?? '—'}</span>
{' · '}{ev.budget.tenNganSach}
{' · '}<span className="text-slate-500">{ev.budget.tongNganSach.toLocaleString('vi-VN')} đ</span>
</a>
) : ev.budgetManualAmount != null || ev.budgetManualName ? (
<span className="text-slate-700">
{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="text-slate-400"></span>}
/>
)
// Inline-edit số tiền VND (reuse formatVndInput/parseVnd module-level). allowNegative
// cho dòng "hiệu chỉnh tăng giảm" (CCM nhập số âm). onSave nhận number|null.
function VndInlineEdit({
initial, allowNegative = false, onSave, saving, label,
}: {
initial: number | null
allowNegative?: boolean
onSave: (v: number | null) => void
saving: boolean
label?: string
}) {
const [text, setText] = useState(initial != null ? Math.abs(initial).toLocaleString('vi-VN') : '')
const [neg, setNeg] = useState((initial ?? 0) < 0)
const parse = (): number | null => {
const n = parseVnd(text)
if (n === 0 && text.trim() === '') return null
return allowNegative && neg ? -n : n
}
// Editable mode (canEdit=true)
const dirty = parse() !== initial
return (
<div className="flex gap-3">
<span className="w-44 shrink-0 pt-1.5 text-[12px] text-slate-500">b. Ngân sách</span>
<div className="min-w-0 flex-1 space-y-2">
<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)
</label>
{!manualMode ? (
<Select
value={budgetId}
onChange={e => setBudgetId(e.target.value)}
className="text-sm"
>
<option value=""></option>
{eligibleBudgets.data?.map(b => (
<option key={b.id} value={b.id}>
{b.maNganSach ?? '—'} · {b.tenNganSach} · {b.tongNganSach.toLocaleString('vi-VN')} đ
</option>
))}
</Select>
) : (
<div className="relative max-w-xs">
<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>
)}
{dirty && (
<div className="flex items-center gap-2">
<Button
onClick={() => save.mutate()}
disabled={save.isPending}
className="h-7 px-3 text-xs"
>
{save.isPending ? 'Đang lưu…' : 'Lưu ngân sách'}
</Button>
<button
onClick={() => {
setManualMode(initialManual)
setBudgetId(ev.budgetId ?? '')
setManualAmount(ev.budgetManualAmount ?? 0)
}}
className="text-[11px] text-slate-500 hover:text-slate-700"
>
Hủy thay đi
</button>
</div>
)}
<div className="flex items-center justify-end gap-1.5">
{allowNegative && (
<button
type="button"
onClick={() => setNeg(v => !v)}
className={cn(
'h-6 w-6 shrink-0 rounded border text-xs font-bold',
neg ? 'border-red-300 bg-red-50 text-red-600' : 'border-slate-300 text-slate-400',
)}
title="Đảo dấu âm/dương"
>
{neg ? '' : '+'}
</button>
)}
<div className="relative w-40">
<Input
type="text"
inputMode="numeric"
value={text}
onChange={e => setText(e.target.value.replace(/[^\d.]/g, ''))}
placeholder="0"
aria-label={label}
className="h-7 pr-6 font-mono text-right text-[13px]"
/>
<span className="pointer-events-none absolute inset-y-0 right-2 flex items-center text-[11px] font-medium text-slate-500">đ</span>
</div>
<Button
onClick={() => onSave(parse())}
disabled={!dirty || saving}
className="h-7 px-2 text-[11px]"
>
{saving ? '…' : 'Lưu'}
</Button>
</div>
)
}
// 1 dòng bảng — label trái | value phải (right-align) | cột 3 (% hoặc ghi chú).
// tone: 'brand' = nền brand đậm chữ trắng (dòng tổng) · 'brand-soft' = nền brand-50.
function BudgetRow({
label, sub, value, third, tone, danger, mono = true,
}: {
label: React.ReactNode
sub?: React.ReactNode
value: React.ReactNode
third?: React.ReactNode
tone?: 'brand' | 'brand-soft' | 'blue-soft'
danger?: boolean
mono?: boolean
}) {
const toneCls =
tone === 'brand' ? 'bg-[#1F7DC1] text-white font-semibold'
: tone === 'brand-soft' ? 'bg-[#1F7DC1]/10'
: tone === 'blue-soft' ? 'bg-blue-50'
: ''
return (
<div className={cn('flex items-start gap-2 border-b border-slate-100 px-3 py-1.5 text-[13px]', toneCls)}>
<div className="min-w-0 flex-1">
<div className={cn(tone === 'brand' ? 'text-white' : 'text-slate-700')}>{label}</div>
{sub && <div className={cn('text-[10px]', tone === 'brand' ? 'text-white/70' : 'text-slate-400')}>{sub}</div>}
</div>
<div className={cn(
'w-48 shrink-0 text-right tabular-nums',
mono && 'font-mono',
danger ? 'font-semibold text-red-600' : tone === 'brand' ? 'font-bold' : 'text-slate-900',
)}>
{value}
</div>
<div className={cn(
'w-24 shrink-0 text-right text-[11px]',
tone === 'brand' ? 'text-white/80' : 'text-slate-500',
)}>
{third}
</div>
</div>
)
}
// ===== 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()
// Block tiêu đề (A / B)
function BudgetBlockHeader({ children }: { children: React.ReactNode }) {
return (
<div className="border-b border-slate-200 bg-slate-100 px-3 py-1.5 text-[11px] font-semibold uppercase tracking-wide text-slate-600">
{children}
</div>
)
}
function PeBudgetSummaryTable({ ev, readOnly }: { ev: PeDetailBundle; readOnly: boolean }) {
const qc = useQueryClient()
const [editing, setEditing] = useState(false)
const bs = ev.budgetSummary
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
// F4 Approver scope (Mig 30): phase ChoDuyet + actor in currentApproval.approvers
// + currentLevel có flag AllowApproverEditBudget=true (admin Designer tick per slot).
const actorInCurrentLevel = ev.currentApproval?.approvers?.some(a => a.userId === currentUser?.id) ?? false
const approverEditBudgetAllowed = ev.currentLevelOptions?.allowApproverEditBudget ?? false
const isApproverChoDuyet = ev.phase === PurchaseEvaluationPhase.ChoDuyet
&& actorInCurrentLevel
&& approverEditBudgetAllowed
// Drafter nhập được row3 (NS kỳ này) + row8 (giá trị thực hiện dự kiến còn lại)
// khi phiếu DangSoanThao/TraLai + !readOnly. Mirror predicate row3/row8 spec.
const drafterEditable = !readOnly && isEditablePhase(ev.phase)
// S23 t2 bug fix: F4 Approver scope BYPASS readOnly (mirror F3 itemsReadOnly
// pattern). Khi admin tick AllowApproverEditBudget cho slot + actor match +
// Phase=ChoDuyet → button "Điều chỉnh" enable trong menu Duyệt (readOnly=true)
// dù chế độ chỉ-đọc. Drafter + Admin vẫn cần !readOnly (chỉ active từ Workspace).
const canAdjust = isAdmin
|| (!readOnly && isDrafter && isDrafterPhase)
|| isApproverChoDuyet
const invalidate = () => {
qc.invalidateQueries({ queryKey: ['pe-detail', ev.id] })
qc.invalidateQueries({ queryKey: ['pe-list'] })
}
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)
// S59 UAT vòng 4 (anh chốt "chỗ tên ngân sách bỏ đi"): bỏ ô "Tên (không bắt buộc)"
// — user không hiểu ý nghĩa; manual budget chỉ còn Số tiền. Tên cũ (phiếu trước)
// vẫn hiển thị read-only, sẽ về null khi Lưu điều chỉnh lần tới.
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,
// PUT /budget/pro — chỉ khi canEditPro. proEstimateAmount + proNote.
const proMut = useMutation({
mutationFn: async (body: { proEstimateAmount: number | null; proNote: string | null }) =>
api.put(`/purchase-evaluations/${ev.id}/budget/pro`, body),
onSuccess: () => { toast.success('Đã lưu dự trù PRO'); invalidate() },
onError: e => toast.error(getErrorMessage(e)),
})
// PUT /budget/ccm — chỉ khi canEditCcm. initialAmount + adjustmentAmount.
const ccmMut = useMutation({
mutationFn: async (body: { initialAmount: number | null; adjustmentAmount: number | null }) =>
api.put(`/purchase-evaluations/${ev.id}/budget/ccm`, body),
onSuccess: () => { toast.success('Đã lưu ngân sách ban hành'); invalidate() },
onError: e => toast.error(getErrorMessage(e)),
})
// PATCH /budget-adjust — ABSOLUTE-SET: BE set thẳng CẢ 2 field (thiếu field =
// null = CLEAR). Mọi call-site PHẢI gửi đủ cặp {budgetPeriodAmount,
// expectedRemainingAmount} (field không đổi → echo giá trị hiện tại từ ev).
const adjustMut = useMutation({
mutationFn: async () => {
const payload = manualMode
? { budgetId: null, budgetManualName: 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)
},
mutationFn: async (body: { budgetPeriodAmount?: number | null; expectedRemainingAmount?: number | null }) =>
api.patch(`/purchase-evaluations/${ev.id}/budget-adjust`, body),
onSuccess: () => { toast.success('Đã lưu'); invalidate() },
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.
// proNote inline-edit state (Textarea — không dùng VndInlineEdit)
const [proNoteText, setProNoteText] = useState(bs?.proNote ?? '')
useEffect(() => { setProNoteText(bs?.proNote ?? '') }, [bs?.proNote])
// 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>
// Phiếu cũ chưa gắn Hạng mục công việc → budgetSummary null.
if (!bs) {
return (
<div className="rounded border border-amber-200 bg-amber-50 px-3 py-2.5 text-[12px] text-amber-800">
Phiếu chưa gắn Hạng mục công việc gắn Hạng mục đ dùng ngân sách gói thầu.
</div>
)
}
// ===== Số liệu Excel =====
const full = bs.fullAmount
const row1 = bs.previousSubmittedTotal // Ngân sách trình duyệt trước
const row2 = bs.previousSelectedTotal // Kỳ trước đã chọn thầu
const row3 = ev.budgetPeriodAmount ?? 0 // Ngân sách - kỳ này (drafter)
const row4 = bs.currentProposalTotal // Giá trị kỳ này (đề xuất NCC được chọn)
const row5 = row1 + row3 // Lũy kế ngân sách đã sử dụng (= 1 + 3)
const row6 = row2 + row4 // Lũy kế thực hiện (= 2 + 4)
const row7 = full - row5 // Ngân sách còn lại
const row8 = ev.expectedRemainingAmount ?? row7 // Giá trị thực hiện dự kiến còn lại
const row9 = row4 + row8 // Giá trị tổng thực hiện dự kiến (= 4 + 8)
const cmpPeriod = row3 - row4 // So sánh với ngân sách kỳ này (row3 row4)
const cmp56 = row5 - row6 // So với NS (row5 row6)
const cmpFull = full - row9 // So sánh với Ngân sách full (full row9)
// Cờ tô màu cảnh báo
const proposalOver = bs.currentProposalTotal > (ev.budgetPeriodAmount ?? 0) && ev.budgetPeriodAmount != null
const remainingOver = ev.expectedRemainingAmount != null && ev.expectedRemainingAmount > row7
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)
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>
)}
<div className="overflow-hidden rounded-lg border border-slate-300">
<div className="bg-[#1F7DC1] px-3 py-2 text-[12px] font-bold uppercase tracking-wide text-white">
Tổng hợp ngân sách trình
</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"
{/* ===== Block A — NGÂN SÁCH (gói thầu) ===== */}
<BudgetBlockHeader>A. Ngân sách (gói thầu)</BudgetBlockHeader>
{/* Dòng 1 — Ngân sách (full gói thầu) — brand đậm */}
<BudgetRow
tone="brand"
label={
<span className="inline-flex items-center gap-2">
Ngân sách (full gói thầu)
{bs.fullIsEstimate && (
<span className="rounded bg-white/20 px-1.5 py-0.5 text-[9px] font-semibold uppercase">dự trù PRO</span>
)}
</span>
}
value={fmtVnd(full)}
/>
{/* Dòng 2 — Ban hành lần đầu (CCM editable) */}
<BudgetRow
label="Ngân sách Ban hành lần đầu"
value={
bs.canEditCcm ? (
<VndInlineEdit
initial={bs.initialAmount}
saving={ccmMut.isPending}
label="Ngân sách ban hành lần đầu"
onSave={v => ccmMut.mutate({ initialAmount: v, adjustmentAmount: bs.adjustmentAmount })}
/>
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="max-w-xs">
<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>
) : bs.initialAmount != null ? fmtVnd(bs.initialAmount) : <span className="text-slate-400"></span>
}
/>
{/* Dòng 3 — Hiệu chỉnh V0 tăng giảm (CCM editable, cho phép âm) */}
<BudgetRow
label="Ngân sách V0 / hiệu chỉnh tăng giảm"
value={
bs.canEditCcm ? (
<VndInlineEdit
initial={bs.adjustmentAmount}
allowNegative
saving={ccmMut.isPending}
label="Ngân sách hiệu chỉnh tăng giảm"
onSave={v => ccmMut.mutate({ initialAmount: bs.initialAmount, adjustmentAmount: v })}
/>
) : bs.adjustmentAmount != null ? (
<span className={cn(bs.adjustmentAmount < 0 && 'text-red-600')}>{fmtVndSigned(bs.adjustmentAmount)}</span>
) : <span className="text-slate-400"></span>
}
/>
{/* Dòng 4 — Dự trù PRO (PRO editable) */}
<BudgetRow
label="Dự trù PRO"
value={
bs.canEditPro ? (
<VndInlineEdit
initial={bs.proEstimateAmount}
saving={proMut.isPending}
label="Dự trù PRO"
onSave={v => proMut.mutate({ proEstimateAmount: v, proNote: proNoteText || null })}
/>
) : bs.proEstimateAmount != null ? fmtVnd(bs.proEstimateAmount) : <span className="text-slate-400"></span>
}
/>
{/* Dòng 5 — Ghi chú từ PRO (PRO editable — Textarea) */}
<div className="flex items-start gap-2 border-b border-slate-100 px-3 py-1.5 text-[13px]">
<div className="min-w-0 flex-1 text-slate-700">Ghi chú từ PRO</div>
<div className="w-72 shrink-0">
{bs.canEditPro ? (
<div className="space-y-1">
<textarea
value={proNoteText}
onChange={e => setProNoteText(e.target.value)}
placeholder="Ghi chú dự trù…"
rows={2}
className="w-full rounded border border-slate-300 px-2 py-1 text-[12px]"
/>
<div className="flex justify-end">
<Button
onClick={() => proMut.mutate({ proEstimateAmount: bs.proEstimateAmount, proNote: proNoteText || null })}
disabled={proNoteText === (bs.proNote ?? '') || proMut.isPending}
className="h-6 px-2 text-[11px]"
>
{proMut.isPending ? '…' : 'Lưu ghi chú'}
</Button>
</div>
</div>
) : (
<div className="whitespace-pre-wrap text-right text-[12px] text-slate-600">
{bs.proNote || <span className="text-slate-400"></span>}
</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>
{/* History defer S22+5 — UAT user xem Panel 3 "Lịch sử thay đổi" */}
{/* ===== Block B — THỰC HIỆN ===== */}
<BudgetBlockHeader>B. Thực hiện</BudgetBlockHeader>
{/* 1 — Ngân sách trình duyệt trước */}
<BudgetRow
label="1. Ngân sách trình duyệt trước"
value={bs.previousSubmittedCount === 0
? <span className="font-sans text-slate-400">Chưa chọn</span>
: fmtVnd(row1)}
/>
{/* 2 — Kỳ trước đã chọn thầu */}
<BudgetRow
label="2. Kỳ trước đã chọn thầu"
value={bs.previousSelectedCount === 0
? <span className="font-sans text-slate-400">Chưa chọn</span>
: fmtVnd(row2)}
/>
{/* 3 — Ngân sách - kỳ này (drafter editable) + % /full */}
<BudgetRow
label="3. Ngân sách - kỳ này"
value={
drafterEditable ? (
<VndInlineEdit
initial={ev.budgetPeriodAmount}
saving={adjustMut.isPending}
label="Ngân sách kỳ này"
onSave={v => adjustMut.mutate({ budgetPeriodAmount: v, expectedRemainingAmount: ev.expectedRemainingAmount })}
/>
) : ev.budgetPeriodAmount != null ? fmtVnd(row3) : <span className="text-slate-400"></span>
}
third={fmtPct(row3, full) ?? undefined}
/>
{/* 4 — Đề xuất kỳ này (block con bg-blue-soft): NCC + giá trị + so sánh */}
<BudgetRow
tone="blue-soft"
label="4. Đề xuất kỳ này — Tên thầu phụ / NCC"
value={
<span className="font-sans text-slate-700">
{ev.selectedSupplierName ?? <span className="text-slate-400"> (chưa chọn)</span>}
</span>
}
/>
<BudgetRow
tone="blue-soft"
label="Giá trị kỳ này"
value={
proposalOver ? (
<span className="inline-block rounded bg-[#C00000] px-2 py-0.5 font-bold text-white">{fmtVnd(row4)}</span>
) : fmtVnd(row4)
}
/>
<BudgetRow
tone="blue-soft"
label="So sánh với ngân sách kỳ này"
sub="= 3 4"
value={<span className={cn(cmpPeriod < 0 && 'font-semibold text-red-600')}>{fmtVndSigned(cmpPeriod)}</span>}
third={fmtPct(cmpPeriod, row3) ?? undefined}
danger={cmpPeriod < 0}
/>
{/* 5 — Lũy kế ngân sách đã sử dụng (= 1 + 3) */}
<BudgetRow
label="5. Lũy kế ngân sách đã sử dụng"
sub="= 1 + 3"
value={fmtVnd(row5)}
/>
{/* 6 — Lũy kế thực hiện (= 2 + 4) + So với NS (5 6) */}
<BudgetRow
label="6. Lũy kế thực hiện"
sub="= 2 + 4"
value={fmtVnd(row6)}
/>
<BudgetRow
label="So với NS"
sub="= 5 6"
value={<span className={cn(cmp56 < 0 && 'font-semibold text-red-600')}>{fmtVndSigned(cmp56)}</span>}
third={fmtPct(cmp56, row5) ?? undefined}
danger={cmp56 < 0}
/>
{/* 7 — Ngân sách còn lại (= full 5) + % /full */}
<BudgetRow
label="7. Ngân sách còn lại"
sub="= Ngân sách full 5"
value={fmtVnd(row7)}
third={fmtPct(row7, full) ?? undefined}
/>
{/* 8 — Giá trị thực hiện dự kiến còn lại (drafter editable) — đỏ nhạt khi > row7 */}
<div className={cn(
'flex items-start gap-2 border-b border-slate-100 px-3 py-1.5 text-[13px]',
remainingOver && 'bg-red-50',
)}>
<div className={cn('min-w-0 flex-1', remainingOver ? 'text-red-700' : 'text-slate-700')}>
8. Giá trị thực hiện dự kiến còn lại
<div className="text-[10px] text-slate-400">mặc đnh = 7 nếu chưa nhập</div>
</div>
<div className="w-48 shrink-0 text-right">
{drafterEditable ? (
<VndInlineEdit
initial={ev.expectedRemainingAmount}
saving={adjustMut.isPending}
label="Giá trị thực hiện dự kiến còn lại"
onSave={v => adjustMut.mutate({ budgetPeriodAmount: ev.budgetPeriodAmount, expectedRemainingAmount: v })}
/>
) : (
<span className={cn('font-mono tabular-nums', remainingOver ? 'font-semibold text-red-700' : 'text-slate-900')}>
{fmtVnd(row8)}
</span>
)}
</div>
<div className="w-24 shrink-0" />
</div>
{/* 9 — Giá trị tổng thực hiện dự kiến (= 4 + 8) — brand đậm */}
<BudgetRow
tone="brand"
label="9. Giá trị tổng thực hiện dự kiến"
sub="= 4 + 8"
value={fmtVnd(row9)}
/>
<BudgetRow
tone="brand-soft"
label="So sánh với Ngân sách full"
sub="= Ngân sách full 9"
value={<span className={cn(cmpFull < 0 && 'font-bold text-red-600')}>{fmtVndSigned(cmpFull)}</span>}
third={fmtPct(cmpFull, full) ?? undefined}
danger={cmpFull < 0}
/>
</div>
)
}
@ -1197,7 +1305,9 @@ function ChonNccSection({ ev, readOnly = false }: { ev: PeDetailBundle; readOnly
return (
<div className="space-y-3">
<NccSelectorRow ev={ev} readOnly={readOnly} />
<BudgetFieldRow ev={ev} readOnly={readOnly} />
{/* b. TỔNG HỢP NGÂN SÁCH TRÌNH KÝ (S61 — Excel anh Kiệt). Thay BudgetFieldRow
+ BudgetAdjustSection cũ (module Budget bỏ hẳn). */}
<PeBudgetSummaryTable ev={ev} readOnly={readOnly} />
<FormRow
label="c. Giá chào thầu"
value={
@ -1620,21 +1730,9 @@ function ItemsTab({ ev, readOnly = false }: { ev: PeDetailBundle; readOnly?: boo
const [addOpen, setAddOpen] = useState(false)
const [editDetail, setEditDetail] = useState<PeDetailRow | null>(null)
// Budget comparison — fetch full Budget bundle nếu có link để so sánh per-row.
const budgetBundle = useQuery({
queryKey: ['budget-detail-for-pe', ev.budgetId],
queryFn: async () => (await api.get<{ details: { groupCode: string; itemCode: string | null; thanhTien: number }[]; tongNganSach: number }>(
`/budgets/${ev.budgetId}`)).data,
enabled: !!ev.budgetId,
})
const budgetRowMap = (() => {
const m = new Map<string, number>()
budgetBundle.data?.details.forEach(d => {
m.set(`${d.groupCode}|${d.itemCode ?? ''}`, d.thanhTien)
})
return m
})()
const showBudgetCol = !!ev.budgetId
// S61 — Budget comparison per-row (cột "NS link" + Δ) XÓA: module Budget bỏ hẳn,
// không còn link PE → Budget entity row-by-row. So sánh ngân sách giờ ở bảng
// TỔNG HỢP NGÂN SÁCH TRÌNH KÝ (Section 2 — PeBudgetSummaryTable).
return (
<div>
@ -1658,8 +1756,6 @@ function ItemsTab({ ev, readOnly = false }: { ev: PeDetailBundle; readOnly?: boo
detail={d}
ev={ev}
readOnly={readOnly}
budgetRowMap={budgetRowMap}
showBudgetCol={showBudgetCol}
onEditDetail={() => setEditDetail(d)}
/>
))}
@ -1675,13 +1771,11 @@ function ItemsTab({ ev, readOnly = false }: { ev: PeDetailBundle; readOnly?: boo
// Card 1 hạng mục — tầng 1 header + tầng 2 NCC grid inline expand.
// Mặc định mở (expanded=true) vì user demo chỉ 1 hạng mục, đỡ click.
function HangMucCard({
detail, ev, readOnly, budgetRowMap, showBudgetCol, onEditDetail,
detail, ev, readOnly, onEditDetail,
}: {
detail: PeDetailRow
ev: PeDetailBundle
readOnly: boolean
budgetRowMap: Map<string, number>
showBudgetCol: boolean
onEditDetail: () => void
}) {
const qc = useQueryClient()
@ -1707,9 +1801,6 @@ function HangMucCard({
onError: e => toast.error(getErrorMessage(e)),
})
const bgValue = budgetRowMap.get(`${detail.groupCode}|${detail.itemCode ?? ''}`)
const delta = bgValue != null ? detail.thanhTienNganSach - bgValue : null
return (
<div className="rounded-lg border border-slate-200 bg-white shadow-sm">
{/* Header row — hạng mục info + actions. Session 20 turn 11: flex-wrap +
@ -1741,20 +1832,9 @@ function HangMucCard({
<span className="ml-1 text-xs font-normal text-slate-500">đ</span>
</div>
</div>
{showBudgetCol && bgValue != null && (
<div className="border-l border-slate-200 pl-3">
<div className="text-[10px] uppercase text-slate-400">NS link</div>
<div className="font-mono text-[11px]">{fmtMoney(bgValue)}</div>
<div className={cn(
'font-mono text-[10px]',
delta! > 0 && 'text-red-600',
delta! < 0 && 'text-emerald-600',
delta === 0 && 'text-slate-500',
)}>
Δ {delta! > 0 ? '+' : ''}{fmtMoney(delta!)}
</div>
</div>
)}
{/* [S61 Mig 50] Cột "NS link" so sánh BudgetDetails cũ ĐÃ GỠ — module
Budget cũ xóa hẳn; so sánh ngân sách giờ ở bảng "Tổng hợp ngân sách
trình ký" cấp phiếu (PeBudgetSummaryTable). */}
</div>
{!readOnly && (
<div className="flex flex-shrink-0 gap-1">

View File

@ -18,8 +18,7 @@ import {
PurchaseEvaluationTypeLabel,
type PeDetailBundle,
} from '@/types/purchaseEvaluation'
import { BudgetPhase, type BudgetListItem } from '@/types/budget'
import type { Paged, Project } from '@/types/master'
import type { Project } from '@/types/master'
// VND format helpers (mirror PeDetailTabs.tsx — session 20)
const parseVnd = (s: string): number => Number(s.replace(/[^\d]/g, '')) || 0
@ -81,29 +80,13 @@ export function PeHeaderForm({
diaDiem: '',
moTa: '',
paymentTerms: '',
budgetId: '' as string,
// Mig 17 — manual budget fallback (toggle "Nhập tay" thay vì link)
budgetManual: false,
budgetManualName: '',
budgetManualAmount: 0,
})
const eligibleBudgets = useQuery({
queryKey: ['eligible-budgets', form.projectId],
queryFn: async () => {
const res = await api.get<Paged<BudgetListItem>>('/budgets', {
params: { pageSize: 100, projectId: form.projectId, phase: BudgetPhase.DaDuyet },
})
return res.data.items
},
enabled: !!form.projectId,
// [S61 Mig 50] "Ngân sách - kỳ này" — thay budgetId/budgetManual* cũ (module
// Budget xóa hẳn; bảng Tổng hợp ngân sách gói thầu nằm ở PeDetailTabs).
budgetPeriodAmount: 0,
})
useEffect(() => {
if (existing.data) {
// S59: manual-mode detect theo CẢ amount (phiếu mới name=null sau khi bỏ ô Tên).
const hasManual = existing.data.budgetManualAmount !== null || existing.data.budgetManualName !== null
|| existing.data.budgetManualAmount !== null
setForm({
type: existing.data.type,
tenGoiThau: existing.data.tenGoiThau,
@ -112,27 +95,16 @@ export function PeHeaderForm({
diaDiem: existing.data.diaDiem ?? '',
moTa: existing.data.moTa ?? '',
paymentTerms: existing.data.paymentTerms ?? '',
budgetId: existing.data.budgetId ?? '',
// Auto-toggle manual mode khi load existing có manual data hoặc không có link
budgetManual: hasManual && !existing.data.budgetId,
budgetManualName: existing.data.budgetManualName ?? '',
budgetManualAmount: existing.data.budgetManualAmount ?? 0,
budgetPeriodAmount: existing.data.budgetPeriodAmount ?? 0,
})
}
}, [existing.data])
// Manual mode: clear budgetId, gửi manualName/Amount. Link mode: clear manual.
const payloadBudgetFields = form.budgetManual
? {
budgetId: null,
budgetManualName: null, // S59 anh chốt bỏ "Tên ngân sách" — manual chỉ còn Số tiền
budgetManualAmount: form.budgetManualAmount > 0 ? form.budgetManualAmount : null,
}
: {
budgetId: form.budgetId || null,
budgetManualName: null,
budgetManualAmount: null,
}
// [S61] PUT UpdateDraft null-safe: budgetPeriodAmount null = GIỮ giá trị cũ
// BE-side; gửi số > 0 mới set. (Clear hẳn → dùng bảng Tổng hợp/budget-adjust.)
const payloadBudgetFields = {
budgetPeriodAmount: form.budgetPeriodAmount > 0 ? form.budgetPeriodAmount : null,
}
const mut = useMutation({
mutationFn: async () => {
@ -239,58 +211,23 @@ export function PeHeaderForm({
</div>
<div>
<div className="mb-1.5 flex items-center justify-between">
<Label className="mb-0">Ngân sách (đi chiếu chi phí)</Label>
{/* Toggle "Nhập tay" — Mig 17 fallback khi không link Budget entity */}
<label className="inline-flex cursor-pointer items-center gap-1.5 text-[11px] text-slate-600">
<input
type="checkbox"
checked={form.budgetManual}
onChange={e => setForm({ ...form, budgetManual: e.target.checked })}
className="h-3.5 w-3.5 rounded border-slate-300"
/>
Nhập tay (không link)
</label>
{/* [S61 Mig 50] Ô đơn "Ngân sách kỳ này" — thay picker Budget cũ + toggle
nhập tay. Số phân bổ cho RIÊNG phiếu này (row 3 bảng Tổng hợp). */}
<Label>Ngân sách kỳ này</Label>
<div className="relative max-w-xs">
<Input
type="text"
inputMode="numeric"
value={formatVndInput(form.budgetPeriodAmount)}
onChange={e => setForm({ ...form, budgetPeriodAmount: parseVnd(e.target.value) })}
placeholder="0"
className="pr-10 font-mono text-right"
/>
<span className="pointer-events-none absolute inset-y-0 right-3 flex items-center text-[12px] font-medium text-slate-500">đ</span>
</div>
{!form.budgetManual ? (
<>
<Select
value={form.budgetId}
disabled={!form.projectId}
onChange={e => setForm({ ...form, budgetId: e.target.value })}
>
<option value=""> (không link)</option>
{eligibleBudgets.data?.map(b => (
<option key={b.id} value={b.id}>
{b.maNganSach ?? '—'} · {b.tenNganSach} · {b.tongNganSach.toLocaleString('vi-VN')} đ
</option>
))}
</Select>
<p className="mt-1 text-[11px] text-slate-500">
{!form.projectId
? 'Chọn dự án trước để xem ngân sách khả dụng.'
: eligibleBudgets.data && eligibleBudgets.data.length === 0
? 'Dự án này chưa có ngân sách đã duyệt — bật "Nhập tay" để nhập số tiền trực tiếp.'
: 'Chỉ list ngân sách đã duyệt cùng dự án.'}
</p>
</>
) : (
<div>
<Label className="text-[11px]">Số tiền</Label>
<div className="relative max-w-xs">
<Input
type="text"
inputMode="numeric"
value={formatVndInput(form.budgetManualAmount)}
onChange={e => setForm({ ...form, budgetManualAmount: parseVnd(e.target.value) })}
placeholder="0"
className="pr-10 font-mono text-right"
/>
<span className="pointer-events-none absolute inset-y-0 right-3 flex items-center text-[12px] font-medium text-slate-500">đ</span>
</div>
<p className="mt-1 text-[11px] text-slate-500">VND nhập số, tự format dấu chấm ngàn (vd 1.000.000)</p>
</div>
)}
<p className="mt-1 text-[11px] text-slate-500">
Số phân bổ cho riêng phiếu này bắt buộc trước khi gửi duyệt. Ngân sách full gói thầu xem bảng "Tổng hợp ngân sách trình ký" trong phiếu.
</p>
</div>
<div>

View File

@ -18,8 +18,7 @@ import { Select } from '@/components/ui/Select'
import { api } from '@/lib/api'
import { getErrorMessage } from '@/lib/apiError'
import { PurchaseEvaluationTypeLabel } from '@/types/purchaseEvaluation'
import { BudgetPhase, type BudgetListItem } from '@/types/budget'
import type { Paged, Project } from '@/types/master'
import type { Project } from '@/types/master'
// VND format helpers (mirror PeDetailTabs.tsx — session 20)
const parseVnd = (s: string): number => Number(s.replace(/[^\d]/g, '')) || 0
@ -59,11 +58,9 @@ export function PeWorkspaceCreateView({
diaDiem: '',
moTa: '',
paymentTerms: '',
budgetId: '',
// Mig 17 — manual budget fallback
budgetManual: false,
budgetManualName: '',
budgetManualAmount: 0,
// [S61 Mig 50] "Ngân sách - kỳ này" — thay budgetId/budgetManual* (module
// Budget cũ xóa hẳn; bảng Tổng hợp ngân sách gói thầu ở PeDetailTabs).
budgetPeriodAmount: 0,
// Mig 23 — Pin quy trình duyệt V2 (User tự chọn lúc tạo)
approvalWorkflowId: '',
})
@ -104,20 +101,9 @@ export function PeWorkspaceCreateView({
},
})
const eligibleBudgets = useQuery({
queryKey: ['eligible-budgets', form.projectId],
queryFn: async () => {
const res = await api.get<Paged<BudgetListItem>>('/budgets', {
params: { pageSize: 100, projectId: form.projectId, phase: BudgetPhase.DaDuyet },
})
return res.data.items
},
enabled: !!form.projectId,
})
const budgetPayload = form.budgetManual
? { budgetId: null, budgetManualName: form.budgetManualName || null, budgetManualAmount: form.budgetManualAmount > 0 ? form.budgetManualAmount : null }
: { budgetId: form.budgetId || null, budgetManualName: null, budgetManualAmount: null }
const budgetPayload = {
budgetPeriodAmount: form.budgetPeriodAmount > 0 ? form.budgetPeriodAmount : null,
}
const create = useMutation({
mutationFn: async () => {
@ -224,7 +210,7 @@ export function PeWorkspaceCreateView({
const loc = p?.location ?? ''
setForm(f => {
const untouched = !f.diaDiem || f.diaDiem === lastAutoLoc.current
return { ...f, projectId: id, budgetId: '', diaDiem: untouched ? loc : f.diaDiem }
return { ...f, projectId: id, diaDiem: untouched ? loc : f.diaDiem }
})
lastAutoLoc.current = loc
}}
@ -258,55 +244,26 @@ export function PeWorkspaceCreateView({
value={<span className="text-slate-400"> (sau khi thêm NCC tham gia + chốt winner)</span>}
/>
{/* b. Ngân sách — editable inline (Mig 17 toggle pattern) */}
{/* [S61 Mig 50] b. Ngân sách kỳ này — ô đơn thay picker Budget cũ +
toggle nhập tay (module Budget xóa hẳn). Số phân bổ cho RIÊNG
phiếu này (row 3 bảng "Tổng hợp ngân sách trình ký"). */}
<div className="flex gap-3">
<span className="w-44 shrink-0 pt-1.5 text-[12px] text-slate-500">b. Ngân sách</span>
<div className="min-w-0 flex-1 space-y-2">
<label className="inline-flex cursor-pointer items-center gap-1.5 text-[11px] text-slate-600">
<input
type="checkbox"
checked={form.budgetManual}
onChange={e => setForm({ ...form, budgetManual: e.target.checked })}
className="h-3.5 w-3.5 rounded border-slate-300"
<span className="w-44 shrink-0 pt-1.5 text-[12px] text-slate-500">b. Ngân sách kỳ này</span>
<div className="min-w-0 flex-1 space-y-1">
<div className="relative max-w-xs">
<Input
type="text"
inputMode="numeric"
value={formatVndInput(form.budgetPeriodAmount)}
onChange={e => setForm({ ...form, budgetPeriodAmount: parseVnd(e.target.value) })}
placeholder="0"
className="pr-10 font-mono text-right text-sm"
/>
Nhập tay (không link)
</label>
{!form.budgetManual ? (
<>
<Select
value={form.budgetId}
disabled={!form.projectId}
onChange={e => setForm({ ...form, budgetId: e.target.value })}
className="text-sm"
>
<option value=""></option>
{eligibleBudgets.data?.map(b => (
<option key={b.id} value={b.id}>
{b.maNganSach ?? '—'} · {b.tenNganSach} · {b.tongNganSach.toLocaleString('vi-VN')} đ
</option>
))}
</Select>
<p className="text-[11px] text-slate-500">
{!form.projectId
? 'Chọn dự án trước để xem ngân sách khả dụng.'
: eligibleBudgets.data && eligibleBudgets.data.length === 0
? 'Dự án này chưa có ngân sách đã duyệt — bật "Nhập tay" để nhập số tiền trực tiếp.'
: 'Chỉ list ngân sách đã duyệt cùng dự án.'}
</p>
</>
) : (
<div className="relative max-w-xs">
<Input
type="text"
inputMode="numeric"
value={formatVndInput(form.budgetManualAmount)}
onChange={e => setForm({ ...form, budgetManualAmount: 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>
)}
<span className="pointer-events-none absolute inset-y-0 right-3 flex items-center text-[12px] font-medium text-slate-500">đ</span>
</div>
<p className="text-[11px] text-slate-500">
Bắt buộc trước khi gửi duyệt. Ngân sách full gói thầu xem bảng &ldquo;Tổng hợp ngân sách trình &rdquo; sau khi tạo phiếu.
</p>
</div>
</div>

View File

@ -26,11 +26,8 @@ export const MenuKeys = {
ApprovalWorkflowsV2: 'ApprovalWorkflowsV2',
AwV2_DuyetNcc: 'AwV2_DuyetNcc',
AwV2_DuyetNccPhuongAn: 'AwV2_DuyetNccPhuongAn',
// Module Ngân sách (Phase 7)
Budgets: 'Budgets',
Bg_List: 'Bg_List',
Bg_Create: 'Bg_Create',
Bg_Pending: 'Bg_Pending',
// [S61 Mig 50] Module Ngân sách cũ (Budgets + Bg_*) XÓA — thay bằng bảng
// "Tổng hợp ngân sách trình ký" per (Dự án, Hạng mục) trong phiếu PE.
// Module Hồ sơ Nhân sự (Mig 34 — Phase 10.1 G-H1 Session 33, 2026-05-26)
Hrm: 'Hrm',
HrmHoSo: 'Hrm_HoSo',

View File

@ -1,173 +0,0 @@
// Create / edit draft Header ngân sách. Hạng mục chỉnh ở Detail tabs sau khi save.
// CreateBudgetCommand BE chỉ cho update Tên/Mô tả/Năm khi DangSoanThao
// — Project/Department khóa sau create.
import { useEffect, useState } from 'react'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { useNavigate, useSearchParams } from 'react-router-dom'
import { toast } from 'sonner'
import { Wallet } from 'lucide-react'
import { Button } from '@/components/ui/Button'
import { Input } from '@/components/ui/Input'
import { Label } from '@/components/ui/Label'
import { Select } from '@/components/ui/Select'
import { Textarea } from '@/components/ui/Textarea'
import { api } from '@/lib/api'
import { getErrorMessage } from '@/lib/apiError'
import type { BudgetDetailBundle } from '@/types/budget'
import type { Department, Paged, Project } from '@/types/master'
export function BudgetCreatePage() {
const navigate = useNavigate()
const qc = useQueryClient()
const [sp] = useSearchParams()
const editId = sp.get('id')
const currentYear = new Date().getFullYear()
const projects = useQuery({
queryKey: ['all-projects'],
queryFn: async () =>
(await api.get<Paged<Project>>('/projects', { params: { pageSize: 1000 } })).data.items,
})
const departments = useQuery({
queryKey: ['all-departments'],
queryFn: async () =>
(await api.get<Paged<Department>>('/departments', { params: { pageSize: 1000 } })).data.items,
})
const existing = useQuery({
queryKey: ['budget-detail', editId],
queryFn: async () => (await api.get<BudgetDetailBundle>(`/budgets/${editId}`)).data,
enabled: !!editId,
})
const [form, setForm] = useState({
tenNganSach: '',
description: '',
namNganSach: currentYear,
projectId: '',
departmentId: '',
})
useEffect(() => {
if (existing.data) {
setForm({
tenNganSach: existing.data.tenNganSach,
description: existing.data.description ?? '',
namNganSach: existing.data.namNganSach,
projectId: existing.data.projectId,
departmentId: existing.data.departmentId ?? '',
})
}
}, [existing.data])
const mut = useMutation({
mutationFn: async () => {
if (editId) {
return api.put(`/budgets/${editId}`, {
id: editId,
tenNganSach: form.tenNganSach,
description: form.description || null,
namNganSach: form.namNganSach,
})
}
return api.post<{ id: string }>('/budgets', {
tenNganSach: form.tenNganSach,
description: form.description || null,
namNganSach: form.namNganSach,
projectId: form.projectId,
departmentId: form.departmentId || null,
})
},
onSuccess: res => {
toast.success(editId ? 'Đã lưu.' : 'Đã tạo ngân sách.')
qc.invalidateQueries({ queryKey: ['budget-list'] })
const id = editId ?? (res as { data: { id: string } }).data.id
navigate(`/budgets?id=${id}`)
},
onError: e => toast.error(getErrorMessage(e)),
})
return (
<div className="space-y-4 p-6">
<header className="flex items-center gap-2">
<Wallet className="h-5 w-5 text-slate-500" />
<h1 className="text-base font-semibold tracking-tight text-slate-900">
{editId ? 'Sửa header ngân sách' : 'Tạo ngân sách mới'}
</h1>
</header>
<div className="max-w-2xl space-y-4 rounded-lg border border-slate-200 bg-white p-6 shadow-sm">
<div>
<Label>Tên ngân sách *</Label>
<Input
value={form.tenNganSach}
onChange={e => setForm({ ...form, tenNganSach: e.target.value })}
placeholder="vd Ngân sách thi công Block A — FLOCK 01"
/>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<Label>Năm ngân sách *</Label>
<Input
type="number"
min={2020}
max={2100}
value={form.namNganSach}
onChange={e => setForm({ ...form, namNganSach: Number(e.target.value) })}
/>
</div>
<div>
<Label>Phòng ban</Label>
<Select
value={form.departmentId}
disabled={!!editId}
onChange={e => setForm({ ...form, departmentId: e.target.value })}
>
<option value=""> (tùy chọn)</option>
{departments.data?.map(d => (
<option key={d.id} value={d.id}>{d.code} {d.name}</option>
))}
</Select>
</div>
</div>
<div>
<Label>Dự án *</Label>
<Select
value={form.projectId}
disabled={!!editId}
onChange={e => setForm({ ...form, projectId: e.target.value })}
>
<option value="">-- Chọn --</option>
{projects.data?.map(p => (
<option key={p.id} value={p.id}>{p.code} {p.name}</option>
))}
</Select>
{editId && (
<p className="mt-1 text-[11px] text-slate-500">Dự án + Phòng ban khóa sau khi tạo.</p>
)}
</div>
<div>
<Label> tả</Label>
<Textarea
rows={4}
value={form.description}
onChange={e => setForm({ ...form, description: e.target.value })}
placeholder="Ghi chú ngân sách, phạm vi sử dụng, ràng buộc..."
/>
</div>
<div className="flex justify-end gap-2">
<Button variant="ghost" onClick={() => navigate(-1)}>Hủy</Button>
<Button
onClick={() => mut.mutate()}
disabled={!form.tenNganSach || !form.projectId || mut.isPending}
>
{editId ? 'Lưu' : 'Tạo ngân sách'}
</Button>
</div>
</div>
</div>
)
}

View File

@ -1,263 +0,0 @@
// List + Detail ngân sách — 3-panel: List | Detail (Header + Hạng mục) | Workflow + history.
// URL params: phase, projectId, q (search), id (selected), namNganSach.
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { useNavigate, useSearchParams } from 'react-router-dom'
import { toast } from 'sonner'
import { Plus, Search, Wallet, X } from 'lucide-react'
import { Input } from '@/components/ui/Input'
import { Select } from '@/components/ui/Select'
import { Button } from '@/components/ui/Button'
import { EmptyState } from '@/components/EmptyState'
import { SlaTimer } from '@/components/SlaTimer'
import { api } from '@/lib/api'
import { getErrorMessage } from '@/lib/apiError'
import { cn } from '@/lib/cn'
import type { Paged } from '@/types/master'
import {
BudgetPhase,
BudgetPhaseColor,
BudgetPhaseLabel,
type BudgetDetailBundle,
type BudgetListItem,
} from '@/types/budget'
import { BudgetDetailTabs } from '@/components/budgets/BudgetDetailTabs'
import { BudgetWorkflowPanel } from '@/components/budgets/BudgetWorkflowPanel'
const fmtMoney = (v: number) => v.toLocaleString('vi-VN')
export function BudgetsListPage() {
const navigate = useNavigate()
const qc = useQueryClient()
const [sp, setSp] = useSearchParams()
const phaseFilter = sp.get('phase') ?? ''
const search = sp.get('q') ?? ''
const yearFilter = sp.get('namNganSach') ?? ''
const selectedId = sp.get('id')
// ?phase=Pending → highlight 2 phase chờ duyệt (ChoCCM + ChoCEO).
const isPendingMode = phaseFilter === 'Pending'
const list = useQuery({
queryKey: ['budget-list', { phaseFilter, search, yearFilter }],
queryFn: async () => {
const params: Record<string, unknown> = { pageSize: 100, search: search || undefined }
if (yearFilter) params.namNganSach = Number(yearFilter)
// Phase=Pending là alias FE → BE không filter (FE tự lọc 2 phase chờ).
if (phaseFilter && phaseFilter !== 'Pending') params.phase = phaseFilter
const res = await api.get<Paged<BudgetListItem>>('/budgets', { params })
return res.data
},
})
const detail = useQuery({
queryKey: ['budget-detail', selectedId],
queryFn: async () => (await api.get<BudgetDetailBundle>(`/budgets/${selectedId}`)).data,
enabled: !!selectedId,
})
const del = useMutation({
mutationFn: async (id: string) => api.delete(`/budgets/${id}`),
onSuccess: () => {
toast.success('Đã xóa ngân sách.')
setParam('id', null)
qc.invalidateQueries({ queryKey: ['budget-list'] })
},
onError: e => toast.error(getErrorMessage(e)),
})
function setParam(key: string, value: string | null) {
const next = new URLSearchParams(sp)
if (value == null || value === '') next.delete(key)
else next.set(key, value)
if (key !== 'id') next.delete('page')
setSp(next, { replace: key === 'q' })
}
function selectRow(id: string) {
if (typeof window !== 'undefined' && window.matchMedia('(min-width: 1024px)').matches) {
setParam('id', id)
} else {
navigate(`/budgets/${id}`)
}
}
const allRows = list.data?.items ?? []
const rows = isPendingMode
? allRows.filter(b => b.phase === BudgetPhase.ChoCCM || b.phase === BudgetPhase.ChoCEO)
: allRows
const headerTitle = isPendingMode ? 'Ngân sách — Chờ duyệt' : 'Ngân sách dự án'
const phaseValues = Object.values(BudgetPhase)
return (
<div className="flex h-[calc(100vh-4rem)] flex-col">
<header className="flex shrink-0 flex-wrap items-center justify-between gap-3 border-b border-slate-200 bg-white px-6 py-3">
<div className="flex items-center gap-2">
<Wallet className="h-5 w-5 text-slate-500" />
<h1 className="text-base font-semibold tracking-tight text-slate-900">{headerTitle}</h1>
<span className="ml-2 rounded-full bg-slate-100 px-2 py-0.5 text-[11px] font-medium text-slate-600">
{rows.length}
</span>
</div>
<Button onClick={() => navigate('/budgets/new')} className="gap-1.5 text-xs">
<Plus className="h-3.5 w-3.5" /> Tạo ngân sách
</Button>
</header>
<div className="grid flex-1 grid-cols-1 overflow-hidden lg:grid-cols-[340px_1fr_360px]">
{/* Panel 1: List */}
<aside className="flex flex-col overflow-hidden border-r border-slate-200 bg-white">
<div className="space-y-2 border-b border-slate-200 p-3">
<div className="relative">
<Search className="pointer-events-none absolute left-2.5 top-2.5 h-4 w-4 text-slate-400" />
<Input
value={search}
onChange={e => setParam('q', e.target.value)}
placeholder="Tìm mã / tên / dự án…"
className="pl-8"
/>
</div>
<div className="grid grid-cols-2 gap-2">
<Select
value={isPendingMode ? '' : phaseFilter}
onChange={e => setParam('phase', e.target.value)}
disabled={isPendingMode}
>
<option value="">Tất cả phase</option>
{phaseValues.map(p => (
<option key={p} value={p}>{BudgetPhaseLabel[p]}</option>
))}
</Select>
<Input
type="number"
placeholder="Năm"
value={yearFilter}
onChange={e => setParam('namNganSach', e.target.value)}
/>
</div>
</div>
<div className="flex-1 overflow-y-auto">
{list.isLoading && (
<div className="space-y-2 p-3">
{Array.from({ length: 6 }).map((_, i) => (
<div key={i} className="h-16 animate-pulse rounded-md bg-slate-100" />
))}
</div>
)}
{!list.isLoading && rows.length === 0 && (
<div className="p-6">
<EmptyState
icon={Wallet}
title={isPendingMode ? 'Không có ngân sách chờ duyệt' : 'Chưa có ngân sách'}
description={isPendingMode ? 'Tất cả đã duyệt xong.' : 'Tạo ngân sách mới để bắt đầu.'}
/>
</div>
)}
<ul className="divide-y divide-slate-100">
{rows.map(b => (
<li key={b.id}>
<button
onClick={() => selectRow(b.id)}
className={cn(
'block w-full px-3 py-2.5 text-left transition hover:bg-slate-50',
selectedId === b.id && 'bg-brand-50 ring-1 ring-inset ring-brand-200',
)}
>
<div className="flex items-start justify-between gap-2">
<div className="min-w-0 flex-1">
<div className="truncate text-[13px] font-medium text-slate-900">{b.tenNganSach}</div>
<div className="mt-0.5 flex items-center gap-1.5 text-[11px] text-slate-500">
<span className="font-mono">{b.maNganSach ?? '—'}</span>
<span>·</span>
<span className="truncate">{b.projectName}</span>
</div>
</div>
<span
className={cn(
'shrink-0 rounded px-1.5 py-0.5 text-[10px] font-medium',
BudgetPhaseColor[b.phase],
)}
>
{BudgetPhaseLabel[b.phase]}
</span>
</div>
<div className="mt-1 flex items-center justify-between text-[11px] text-slate-500">
<span className="rounded bg-slate-100 px-1.5 py-0.5 text-slate-600">
Năm {b.namNganSach}
</span>
<span className="font-medium tabular-nums text-slate-700">
{fmtMoney(b.tongNganSach)} đ
</span>
</div>
<div className="mt-1 text-right">
<SlaTimer deadline={b.slaDeadline} createdAt={b.createdAt} />
</div>
</button>
</li>
))}
</ul>
</div>
</aside>
{/* Panel 2: Detail tabs */}
<main className="hidden overflow-y-auto bg-slate-50 p-6 lg:block">
{!selectedId && (
<EmptyState
icon={Wallet}
title="Chọn ngân sách ở danh sách"
description="Chi tiết hạng mục + duyệt sẽ hiển thị ở đây."
/>
)}
{selectedId && detail.isLoading && <div className="text-sm text-slate-500">Đang tải</div>}
{selectedId && detail.data && (
<BudgetDetailTabs
budget={detail.data}
onBack={() => setParam('id', null)}
onDelete={() => del.mutate(detail.data!.id)}
readOnly={isPendingMode}
/>
)}
</main>
{/* Panel 3: Workflow + history */}
<aside className="hidden overflow-y-auto border-l border-slate-200 bg-white p-4 lg:block">
{!selectedId && (
<div className="rounded-lg border border-dashed border-slate-200 p-6 text-center text-sm text-slate-400">
<X className="mx-auto mb-2 h-5 w-5" />
Quy trình duyệt sẽ hiện khi chọn ngân sách.
</div>
)}
{selectedId && detail.data && <BudgetWorkflowPanel budget={detail.data} />}
</aside>
</div>
</div>
)
}
// Fullpage detail route cho mobile (/budgets/:id)
export function BudgetDetailPage() {
const navigate = useNavigate()
const id = location.pathname.split('/').pop()!
const detail = useQuery({
queryKey: ['budget-detail', id],
queryFn: async () => (await api.get<BudgetDetailBundle>(`/budgets/${id}`)).data,
})
const del = useMutation({
mutationFn: async () => api.delete(`/budgets/${id}`),
onSuccess: () => {
toast.success('Đã xóa.')
navigate('/budgets')
},
})
if (detail.isLoading) return <div className="p-6 text-sm text-slate-500">Đang tải</div>
if (!detail.data) return <div className="p-6 text-sm text-red-600">Không tìm thấy ngân sách.</div>
return (
<div className="space-y-4 p-6">
<BudgetDetailTabs
budget={detail.data}
onBack={() => navigate('/budgets')}
onDelete={() => del.mutate()}
/>
<BudgetWorkflowPanel budget={detail.data} />
</div>
)
}

View File

@ -35,7 +35,6 @@ import {
type ContractDetail,
type ContractListItem,
} from '@/types/contracts'
import { BudgetPhase, type BudgetListItem } from '@/types/budget'
const fmtMoney = (v: number) => v.toLocaleString('vi-VN')
@ -304,9 +303,8 @@ function ContractHeaderForm({
const [tenHopDong, setTenHopDong] = useState('')
const [noiDung, setNoiDung] = useState('')
const [bypass, setBypass] = useState(false)
const [budgetId, setBudgetId] = useState('')
// Mig 17 — manual budget fallback (toggle "Nhập tay")
const [budgetManual, setBudgetManual] = useState(false)
// [S61 Mig 50] Module Budget cũ XÓA — HĐ GIỮ ngân sách nhập tay (Tên + Số tiền),
// chỉ bỏ chế độ link Budget entity.
const [budgetManualName, setBudgetManualName] = useState('')
const [budgetManualAmount, setBudgetManualAmount] = useState(0)
// [Plan B S29 Chunk D 2026-05-22 Mig 32] V2 workflow pin lúc create — mirror
@ -317,8 +315,6 @@ function ContractHeaderForm({
// Reset type về default khi typeFilter (parent prop) thay đổi
useEffect(() => { setType(defaultType) }, [defaultType])
// Reset budget khi đổi project (mỗi project có ngân sách riêng)
useEffect(() => { setBudgetId('') }, [projectId])
const suppliers = useQuery({
queryKey: ['suppliers-all'],
@ -345,21 +341,12 @@ function ContractHeaderForm({
return (typeBucket?.history ?? []).filter(w => w.isUserSelectable)
},
})
// Eligible Budgets: cùng Project + Phase=DaDuyet (BE-side filter).
const eligibleBudgets = useQuery({
queryKey: ['eligible-budgets', projectId],
queryFn: async () =>
(await api.get<Paged<BudgetListItem>>('/budgets', {
params: { pageSize: 100, projectId, phase: BudgetPhase.DaDuyet },
})).data.items,
enabled: !!projectId,
})
const qc = useQueryClient()
// Manual mode: clear budgetId, gửi manualName/Amount. Link mode: clear manual.
const budgetPayload = budgetManual
? { budgetId: null, budgetManualName: budgetManualName || null, budgetManualAmount: budgetManualAmount > 0 ? budgetManualAmount : null }
: { budgetId: budgetId || null, budgetManualName: null, budgetManualAmount: null }
// [S61 Mig 50] HĐ chỉ còn ngân sách nhập tay (link Budget entity đã bỏ).
const budgetPayload = {
budgetManualName: budgetManualName || null,
budgetManualAmount: budgetManualAmount > 0 ? budgetManualAmount : null,
}
const create = useMutation({
mutationFn: async () => {
@ -440,69 +427,35 @@ function ContractHeaderForm({
)}
</div>
<div className="mt-4 space-y-1.5">
<div className="flex items-center justify-between">
<Label className="mb-0">Ngân sách (đi chiếu chi phí)</Label>
{/* Toggle "Nhập tay" — Mig 17 fallback khi không link Budget entity */}
<label className="inline-flex cursor-pointer items-center gap-1.5 text-[11px] text-slate-600">
<input
type="checkbox"
checked={budgetManual}
onChange={e => setBudgetManual(e.target.checked)}
className="h-3.5 w-3.5 rounded border-slate-300"
{/* [S61 Mig 50] HĐ giữ ngân sách NHẬP TAY (Tên + Số tiền) — chế độ link
Budget entity đã bỏ (module Budget cũ xóa hẳn). */}
<Label className="mb-0">Ngân sách (đi chiếu chi phí nhập tay)</Label>
<div className="grid grid-cols-1 gap-2 md:grid-cols-2">
<div>
<Label className="text-[11px]">Tên ngân sách (tham chiếu)</Label>
<Input
value={budgetManualName}
onChange={e => setBudgetManualName(e.target.value)}
placeholder="vd Tạm tính dự toán T11/2025"
maxLength={200}
/>
Nhập tay (không link)
</label>
</div>
{!budgetManual ? (
<>
<Select
value={budgetId}
disabled={!projectId}
onChange={e => setBudgetId(e.target.value)}
>
<option value=""> (không link)</option>
{eligibleBudgets.data?.map(b => (
<option key={b.id} value={b.id}>
{b.maNganSach ?? '—'} · {b.tenNganSach} · {b.tongNganSach.toLocaleString('vi-VN')} đ
</option>
))}
</Select>
<p className="text-[11px] text-slate-500">
{!projectId
? 'Chọn dự án trước để xem ngân sách khả dụng.'
: eligibleBudgets.data && eligibleBudgets.data.length === 0
? 'Dự án này chưa có ngân sách đã duyệt — bật "Nhập tay" để nhập số tiền trực tiếp.'
: 'Chỉ list ngân sách đã duyệt cùng dự án.'}
</p>
</>
) : (
<div className="grid grid-cols-1 gap-2 md:grid-cols-2">
<div>
<Label className="text-[11px]">Tên ngân sách (tham chiếu)</Label>
<Input
value={budgetManualName}
onChange={e => setBudgetManualName(e.target.value)}
placeholder="vd Tạm tính dự toán T11/2025"
maxLength={200}
/>
</div>
<div>
<Label className="text-[11px]">Số tiền (đ)</Label>
<Input
type="number"
min={0}
value={budgetManualAmount || ''}
onChange={e => setBudgetManualAmount(Number(e.target.value))}
placeholder="1000000000"
/>
{budgetManualAmount > 0 && (
<p className="mt-1 text-[11px] text-slate-500">
{budgetManualAmount.toLocaleString('vi-VN')} đ
</p>
)}
</div>
</div>
)}
<div>
<Label className="text-[11px]">Số tiền (đ)</Label>
<Input
type="number"
min={0}
value={budgetManualAmount || ''}
onChange={e => setBudgetManualAmount(Number(e.target.value))}
placeholder="1000000000"
/>
{budgetManualAmount > 0 && (
<p className="mt-1 text-[11px] text-slate-500">
{budgetManualAmount.toLocaleString('vi-VN')} đ
</p>
)}
</div>
</div>
</div>
<div className="mt-4 flex justify-end gap-2">
<Button type="submit" disabled={create.isPending}>
@ -592,10 +545,7 @@ function ContractEditForm({
const [giaTri, setGiaTri] = useState(String(contract.giaTri ?? 0))
const [tenHopDong, setTenHopDong] = useState(contract.tenHopDong ?? '')
const [noiDung, setNoiDung] = useState(contract.noiDung ?? '')
const [budgetId, setBudgetId] = useState(contract.budgetId ?? '')
// Mig 17 — manual budget fallback. Auto-toggle khi load có manual data
const hasInitialManual = contract.budgetManualName !== null || contract.budgetManualAmount !== null
const [budgetManual, setBudgetManual] = useState(hasInitialManual && !contract.budgetId)
// [S61 Mig 50] HĐ giữ ngân sách nhập tay — link Budget entity đã bỏ.
const [budgetManualName, setBudgetManualName] = useState(contract.budgetManualName ?? '')
const [budgetManualAmount, setBudgetManualAmount] = useState(contract.budgetManualAmount ?? 0)
@ -603,20 +553,12 @@ function ContractEditForm({
queryKey: ['templates-by-type', contract.type],
queryFn: async () => (await api.get<ContractTemplate[]>('/forms/templates', { params: { type: contract.type } })).data,
})
// Eligible Budgets: cùng Project + Phase=DaDuyet
const eligibleBudgets = useQuery({
queryKey: ['eligible-budgets', contract.projectId],
queryFn: async () =>
(await api.get<Paged<BudgetListItem>>('/budgets', {
params: { pageSize: 100, projectId: contract.projectId, phase: BudgetPhase.DaDuyet },
})).data.items,
enabled: isDraft,
})
const qc = useQueryClient()
const budgetPayload = budgetManual
? { budgetId: null, budgetManualName: budgetManualName || null, budgetManualAmount: budgetManualAmount > 0 ? budgetManualAmount : null }
: { budgetId: budgetId || null, budgetManualName: null, budgetManualAmount: null }
const budgetPayload = {
budgetManualName: budgetManualName || null,
budgetManualAmount: budgetManualAmount > 0 ? budgetManualAmount : null,
}
const update = useMutation({
mutationFn: async () => {
@ -716,74 +658,36 @@ function ContractEditForm({
<Textarea rows={4} value={noiDung} onChange={e => setNoiDung(e.target.value)} disabled={!isDraft} />
</div>
<div className="col-span-2 space-y-1.5">
<div className="flex items-center justify-between">
<Label className="mb-0">Ngân sách (đi chiếu chi phí)</Label>
{isDraft && (
<label className="inline-flex cursor-pointer items-center gap-1.5 text-[11px] text-slate-600">
<input
type="checkbox"
checked={budgetManual}
onChange={e => setBudgetManual(e.target.checked)}
className="h-3.5 w-3.5 rounded border-slate-300"
/>
Nhập tay (không link)
</label>
)}
</div>
{/* [S61 Mig 50] HĐ giữ ngân sách NHẬP TAY — link Budget entity đã bỏ
(module Budget cũ xóa hẳn). */}
<Label className="mb-0">Ngân sách (đi chiếu chi phí nhập tay)</Label>
{isDraft ? (
!budgetManual ? (
<>
<Select value={budgetId} onChange={e => setBudgetId(e.target.value)}>
<option value=""> (không link)</option>
{eligibleBudgets.data?.map(b => (
<option key={b.id} value={b.id}>
{b.maNganSach ?? '—'} · {b.tenNganSach} · {b.tongNganSach.toLocaleString('vi-VN')} đ
</option>
))}
</Select>
<p className="text-[11px] text-slate-500">
{eligibleBudgets.data && eligibleBudgets.data.length === 0
? 'Dự án này chưa có ngân sách đã duyệt — bật "Nhập tay" để nhập số tiền trực tiếp.'
: 'Chỉ list ngân sách đã duyệt cùng dự án.'}
</p>
</>
) : (
<div className="grid grid-cols-1 gap-2 md:grid-cols-2">
<div>
<Label className="text-[11px]">Tên ngân sách (tham chiếu)</Label>
<Input
value={budgetManualName}
onChange={e => setBudgetManualName(e.target.value)}
placeholder="vd Tạm tính dự toán T11/2025"
maxLength={200}
/>
</div>
<div>
<Label className="text-[11px]">Số tiền (đ)</Label>
<Input
type="number"
min={0}
value={budgetManualAmount || ''}
onChange={e => setBudgetManualAmount(Number(e.target.value))}
placeholder="1000000000"
/>
{budgetManualAmount > 0 && (
<p className="mt-1 text-[11px] text-slate-500">
{budgetManualAmount.toLocaleString('vi-VN')} đ
</p>
)}
</div>
<div className="grid grid-cols-1 gap-2 md:grid-cols-2">
<div>
<Label className="text-[11px]">Tên ngân sách (tham chiếu)</Label>
<Input
value={budgetManualName}
onChange={e => setBudgetManualName(e.target.value)}
placeholder="vd Tạm tính dự toán T11/2025"
maxLength={200}
/>
</div>
)
) : contract.budget ? (
<a
href={`/budgets?id=${contract.budget.id}`}
className="block rounded border border-slate-200 bg-slate-50 px-3 py-2 text-sm text-slate-700 hover:bg-brand-50 hover:text-brand-700"
>
<span className="font-mono text-[11px]">{contract.budget.maNganSach ?? '—'}</span>
{' · '}{contract.budget.tenNganSach}
{' · '}<span className="text-slate-500">{contract.budget.tongNganSach.toLocaleString('vi-VN')} đ</span>
</a>
<div>
<Label className="text-[11px]">Số tiền (đ)</Label>
<Input
type="number"
min={0}
value={budgetManualAmount || ''}
onChange={e => setBudgetManualAmount(Number(e.target.value))}
placeholder="1000000000"
/>
{budgetManualAmount > 0 && (
<p className="mt-1 text-[11px] text-slate-500">
{budgetManualAmount.toLocaleString('vi-VN')} đ
</p>
)}
</div>
</div>
) : contract.budgetManualAmount != null || contract.budgetManualName ? (
// Mig 17 — read-only display khi !isDraft + có manual data
<div className="block rounded border border-slate-200 bg-slate-50 px-3 py-2 text-sm text-slate-700">

View File

@ -1,168 +0,0 @@
// Types cho module Ngân sách (Budget) — mirror BE Domain.Budgets.
// Workflow simple 3-step (BudgetPolicy.Default hardcoded):
// DangSoanThao → ChoCCM → ChoCEO → DaDuyet (+ TuChoi từ DangSoanThao).
export const BudgetPhase = {
DangSoanThao: 1,
ChoCCM: 2,
ChoCEO: 3,
DaDuyet: 4,
TraLai: 98,
TuChoi: 99,
} as const
export type BudgetPhase = typeof BudgetPhase[keyof typeof BudgetPhase]
export const BudgetPhaseLabel: Record<number, string> = {
1: 'Nháp',
2: 'Chờ CCM',
3: 'Chờ CEO',
4: 'Đã duyệt',
98: 'Trả lại',
99: 'Từ chối',
}
export const BudgetPhaseColor: Record<number, string> = {
1: 'bg-slate-100 text-slate-700',
2: 'bg-indigo-100 text-indigo-700',
3: 'bg-pink-100 text-pink-700',
4: 'bg-emerald-100 text-emerald-700',
98: 'bg-yellow-100 text-yellow-800',
99: 'bg-red-100 text-red-700',
}
// Mirror BE BudgetEntityType enum
export const BudgetEntityType = {
Header: 1,
Detail: 2,
Workflow: 3,
} as const
// Mirror BE ChangelogAction enum (reuse từ Contracts.ChangelogAction)
export const ChangelogAction = {
Insert: 1,
Update: 2,
Delete: 3,
Transition: 4,
} as const
// Mirror BE ApprovalDecision enum (reuse từ Contracts.ApprovalDecision)
export const ApprovalDecision = {
Approve: 1,
Reject: 2,
AutoApprove: 3,
} as const
// 2-stage department approval (Migration 16) — Stage 1=Review NV, 2=Confirm TPB.
export const ApprovalStage = {
Review: 1,
Confirm: 2,
} as const
export type ApprovalStage = typeof ApprovalStage[keyof typeof ApprovalStage]
export type BudgetDepartmentApproval = {
id: string
phaseAtApproval: number
departmentId: string
departmentName: string | null
stage: number // 1=Review, 2=Confirm
approverUserId: string
approverName: string | null
approverRoleSnapshot: string | null // "TPB" | "NV" | "NV(bypass)"
comment: string | null
approvedAt: string
isBypassed: boolean
}
export type BudgetListItem = {
id: string
maNganSach: string | null
tenNganSach: string
namNganSach: number
phase: number
projectId: string
projectName: string
tongNganSach: number
slaDeadline: string | null
createdAt: string
}
export type BudgetDetailRow = {
id: string
groupCode: string
groupName: string
itemCode: string | null
noiDung: string
donViTinh: string | null
khoiLuong: number
donGia: number
thanhTien: number
order: number
ghiChu: string | null
}
export type BudgetApproval = {
id: string
fromPhase: number
toPhase: number
approverUserId: string | null
approverName: string | null
decision: number
comment: string | null
approvedAt: string
}
export type BudgetWorkflowSummary = {
policyName: string
policyDescription: string
activePhases: number[]
nextPhases: number[]
}
export type BudgetChangelog = {
id: string
entityType: number
entityId: string | null
action: number
phaseAtChange: number | null
userId: string | null
userName: string | null
summary: string | null
fieldChangesJson: string | null
contextNote: string | null
createdAt: string
}
export type BudgetDetailBundle = {
id: string
maNganSach: string | null
tenNganSach: string
description: string | null
namNganSach: number
phase: number
projectId: string
projectName: string
departmentId: string | null
departmentName: string | null
drafterUserId: string | null
drafterName: string | null
tongNganSach: number
slaDeadline: string | null
createdAt: string
updatedAt: string | null
details: BudgetDetailRow[]
approvals: BudgetApproval[]
workflow: BudgetWorkflowSummary
}
// Body shape POST/PUT detail — mirror BudgetDetailBody record BE.
export type BudgetDetailBody = {
groupCode: string
groupName: string
itemCode: string | null
noiDung: string
donViTinh: string | null
khoiLuong: number
donGia: number
thanhTien: number
ghiChu: string | null
}

View File

@ -130,16 +130,6 @@ export type WorkflowSummary = {
nextPhases: number[]
}
// Snapshot ngân sách link cho Contract (cùng shape với BudgetSummaryDto BE).
export type ContractBudgetSummary = {
id: string
maNganSach: string | null
tenNganSach: string
namNganSach: number
phase: number
tongNganSach: number
}
export type ContractDetail = {
id: string
maHopDong: string | null
@ -162,9 +152,8 @@ export type ContractDetail = {
draftData: string | null
createdAt: string
updatedAt: string | null
budgetId: string | null
budget: ContractBudgetSummary | null
// Mig 17 — manual budget fallback khi không link Budget entity.
// [S61 Mig 50] budgetId/budget link DROP (module Budget cũ xóa) — HĐ GIỮ
// ngân sách nhập tay.
budgetManualName: string | null
budgetManualAmount: number | null
approvals: ContractApproval[]

View File

@ -131,6 +131,9 @@ export type PeListItem = {
drafterName: string | null
departmentId: string | null
departmentName: string | null
// S61 — 2 cột ngân sách mới (list DTO mirror detail; chưa render ở list UI)
budgetPeriodAmount: number | null
expectedRemainingAmount: number | null
}
export type PeSupplier = {
@ -275,14 +278,28 @@ export type PeChangelog = {
createdAt: string
}
// Snapshot ngân sách link (compact — cùng shape BudgetSummaryDto BE).
export type BudgetSummary = {
id: string
maNganSach: string | null
tenNganSach: string
namNganSach: number
phase: number
tongNganSach: number
// S61 — Ngân sách gói thầu (PeWorkItemBudgets: 1 record/cặp Dự án × Hạng mục).
// BE compute + trả kèm PE detail GET. fullAmount = (initial??0)+(adjustment??0);
// cả 2 null → fallback proEstimate??0 + fullIsEstimate=true (badge "dự trù PRO").
// canEditPro = role Procurement|Admin · canEditCcm = CostControl|Admin — BE-computed
// capability flag (pattern S54 — FE KHÔNG đoán role). budgetId=null khi phiếu chưa
// gắn Hạng mục công việc (phiếu cũ) → totals=0 + FE banner nhắc gắn hạng mục.
export type PeBudgetSummary = {
budgetId: string | null
proEstimateAmount: number | null
proNote: string | null
initialAmount: number | null
adjustmentAmount: number | null // CCM "NS V0/hiệu chỉnh" — cho phép ÂM
fullAmount: number
fullIsEstimate: boolean
canEditPro: boolean
canEditCcm: boolean
// Lũy kế các phiếu cùng (ProjectId, WorkItemId), Id != this, CreatedAt < this:
previousSubmittedTotal: number // SUM BudgetPeriodAmount WHERE Phase IN (ChoDuyet, DaDuyet)
previousSubmittedCount: number
previousSelectedTotal: number // SUM quote ThanhTien của SelectedSupplier WHERE Phase=DaDuyet
previousSelectedCount: number
currentProposalTotal: number // SUM ThanhTien quotes của SelectedSupplier phiếu NÀY (0 khi chưa chọn)
}
// Mirror BE PeDepartmentKind enum
@ -408,11 +425,10 @@ export type PeDetailBundle = {
slaDeadline: string | null
createdAt: string
updatedAt: string | null
budgetId: string | null
budget: BudgetSummary | null
// Mig 17 — manual budget fallback khi không link Budget entity. Cả 2 cùng null OK.
budgetManualName: string | null
budgetManualAmount: number | null
// S61 — Ngân sách PE mới (module Budget cũ XÓA HẲN — budgetId/budget/budgetManual* DROP):
budgetPeriodAmount: number | null // 'Ngân sách - kỳ này' (row 3 Excel) — drafter nhập
expectedRemainingAmount: number | null // 'Giá trị thực hiện dự kiến còn lại' (row 8) — null = FE default NS còn lại
budgetSummary: PeBudgetSummary | null // bảng TỔNG HỢP NGÂN SÁCH TRÌNH KÝ (BE compute)
// Mig 23 — Pin schema mới ApprovalWorkflowsV2 (User chọn lúc create).
approvalWorkflowId: string | null
approvalWorkflowCode: string | null