-
Ngân sách (đối chiếu chi phí)
-
setForm({ ...form, budgetId: e.target.value })}
- >
- — (không link)
- {eligibleBudgets.data?.map(b => (
-
- {b.maNganSach ?? '—'} · {b.tenNganSach} · {b.tongNganSach.toLocaleString('vi-VN')} đ
-
- ))}
-
-
- {!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.'
- : 'Chỉ list ngân sách đã duyệt cùng dự án.'}
-
+
+ Ngân sách (đối chiếu chi phí)
+ {/* Toggle "Nhập tay" — Mig 17 fallback khi không link Budget entity */}
+
+ setForm({ ...form, budgetManual: e.target.checked })}
+ className="h-3.5 w-3.5 rounded border-slate-300"
+ />
+ Nhập tay (không link)
+
+
+ {!form.budgetManual ? (
+ <>
+
setForm({ ...form, budgetId: e.target.value })}
+ >
+ — (không link)
+ {eligibleBudgets.data?.map(b => (
+
+ {b.maNganSach ?? '—'} · {b.tenNganSach} · {b.tongNganSach.toLocaleString('vi-VN')} đ
+
+ ))}
+
+
+ {!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.'}
+
+ >
+ ) : (
+
+
+ Tên ngân sách (tham chiếu)
+ setForm({ ...form, budgetManualName: e.target.value })}
+ placeholder="vd Tạm tính dự toán T11/2025"
+ maxLength={200}
+ />
+
+
+
Số tiền (đ)
+
setForm({ ...form, budgetManualAmount: Number(e.target.value) })}
+ placeholder="1000000000"
+ />
+ {form.budgetManualAmount > 0 && (
+
+ ≈ {form.budgetManualAmount.toLocaleString('vi-VN')} đ
+
+ )}
+
+
+ )}
diff --git a/fe-user/src/pages/contracts/ContractCreatePage.tsx b/fe-user/src/pages/contracts/ContractCreatePage.tsx
index 6d3d889..a1d4a3e 100644
--- a/fe-user/src/pages/contracts/ContractCreatePage.tsx
+++ b/fe-user/src/pages/contracts/ContractCreatePage.tsx
@@ -305,6 +305,10 @@ function ContractHeaderForm({
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)
+ const [budgetManualName, setBudgetManualName] = useState('')
+ const [budgetManualAmount, setBudgetManualAmount] = useState(0)
// Reset type về default khi typeFilter (parent prop) thay đổi
useEffect(() => { setType(defaultType) }, [defaultType])
@@ -334,6 +338,11 @@ function ContractHeaderForm({
})
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 }
+
const create = useMutation({
mutationFn: async () => {
const res = await api.post<{ id: string }>('/contracts', {
@@ -347,7 +356,7 @@ function ContractHeaderForm({
noiDung: noiDung || null,
bypassProcurementAndCCM: bypass,
draftData: null,
- budgetId: budgetId || null,
+ ...budgetPayload,
})
return res.data.id
},
@@ -387,26 +396,69 @@ function ContractHeaderForm({
typeReadonly={false}
/>
-
Ngân sách (đối chiếu chi phí)
-
setBudgetId(e.target.value)}
- >
- — (không link)
- {eligibleBudgets.data?.map(b => (
-
- {b.maNganSach ?? '—'} · {b.tenNganSach} · {b.tongNganSach.toLocaleString('vi-VN')} đ
-
- ))}
-
-
- {!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.'
- : 'Chỉ list ngân sách đã duyệt cùng dự án.'}
-
+
+ Ngân sách (đối chiếu chi phí)
+ {/* Toggle "Nhập tay" — Mig 17 fallback khi không link Budget entity */}
+
+ setBudgetManual(e.target.checked)}
+ className="h-3.5 w-3.5 rounded border-slate-300"
+ />
+ Nhập tay (không link)
+
+
+ {!budgetManual ? (
+ <>
+
setBudgetId(e.target.value)}
+ >
+ — (không link)
+ {eligibleBudgets.data?.map(b => (
+
+ {b.maNganSach ?? '—'} · {b.tenNganSach} · {b.tongNganSach.toLocaleString('vi-VN')} đ
+
+ ))}
+
+
+ {!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.'}
+
+ >
+ ) : (
+
+
+ Tên ngân sách (tham chiếu)
+ setBudgetManualName(e.target.value)}
+ placeholder="vd Tạm tính dự toán T11/2025"
+ maxLength={200}
+ />
+
+
+
Số tiền (đ)
+
setBudgetManualAmount(Number(e.target.value))}
+ placeholder="1000000000"
+ />
+ {budgetManualAmount > 0 && (
+
+ ≈ {budgetManualAmount.toLocaleString('vi-VN')} đ
+
+ )}
+
+
+ )}
@@ -497,6 +549,11 @@ function ContractEditForm({
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)
+ const [budgetManualName, setBudgetManualName] = useState(contract.budgetManualName ?? '')
+ const [budgetManualAmount, setBudgetManualAmount] = useState(contract.budgetManualAmount ?? 0)
const templates = useQuery({
queryKey: ['templates-by-type', contract.type],
@@ -513,6 +570,10 @@ function ContractEditForm({
})
const qc = useQueryClient()
+ const budgetPayload = budgetManual
+ ? { budgetId: null, budgetManualName: budgetManualName || null, budgetManualAmount: budgetManualAmount > 0 ? budgetManualAmount : null }
+ : { budgetId: budgetId || null, budgetManualName: null, budgetManualAmount: null }
+
const update = useMutation({
mutationFn: async () => {
await api.put(`/contracts/${contract.id}`, {
@@ -522,7 +583,7 @@ function ContractEditForm({
noiDung: noiDung || null,
templateId: templateId || null,
draftData: null,
- budgetId: budgetId || null,
+ ...budgetPayload,
})
},
onSuccess: () => {
@@ -611,23 +672,65 @@ function ContractEditForm({
-
Ngân sách (đối chiếu chi phí)
+
+ Ngân sách (đối chiếu chi phí)
+ {isDraft && (
+
+ setBudgetManual(e.target.checked)}
+ className="h-3.5 w-3.5 rounded border-slate-300"
+ />
+ Nhập tay (không link)
+
+ )}
+
{isDraft ? (
- <>
-
setBudgetId(e.target.value)}>
- — (không link)
- {eligibleBudgets.data?.map(b => (
-
- {b.maNganSach ?? '—'} · {b.tenNganSach} · {b.tongNganSach.toLocaleString('vi-VN')} đ
-
- ))}
-
-
- {eligibleBudgets.data && eligibleBudgets.data.length === 0
- ? 'Dự án này chưa có ngân sách đã duyệt.'
- : 'Chỉ list ngân sách đã duyệt cùng dự án.'}
-
- >
+ !budgetManual ? (
+ <>
+
setBudgetId(e.target.value)}>
+ — (không link)
+ {eligibleBudgets.data?.map(b => (
+
+ {b.maNganSach ?? '—'} · {b.tenNganSach} · {b.tongNganSach.toLocaleString('vi-VN')} đ
+
+ ))}
+
+
+ {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.'}
+
+ >
+ ) : (
+
+
+ Tên ngân sách (tham chiếu)
+ setBudgetManualName(e.target.value)}
+ placeholder="vd Tạm tính dự toán T11/2025"
+ maxLength={200}
+ />
+
+
+
Số tiền (đ)
+
setBudgetManualAmount(Number(e.target.value))}
+ placeholder="1000000000"
+ />
+ {budgetManualAmount > 0 && (
+
+ ≈ {budgetManualAmount.toLocaleString('vi-VN')} đ
+
+ )}
+
+
+ )
) : contract.budget ? (
{contract.budget.tongNganSach.toLocaleString('vi-VN')} đ
+ ) : contract.budgetManualAmount != null || contract.budgetManualName ? (
+ // Mig 17 — read-only display khi !isDraft + có manual data
+
+ {contract.budgetManualName && {contract.budgetManualName} }
+ {contract.budgetManualName && contract.budgetManualAmount != null && ' · '}
+ {contract.budgetManualAmount != null && (
+ {contract.budgetManualAmount.toLocaleString('vi-VN')} đ
+ )}
+ nhập tay
+
) : (
)}
diff --git a/fe-user/src/pages/pe/PurchaseEvaluationCreatePage.tsx b/fe-user/src/pages/pe/PurchaseEvaluationCreatePage.tsx
index 4312e18..5da3417 100644
--- a/fe-user/src/pages/pe/PurchaseEvaluationCreatePage.tsx
+++ b/fe-user/src/pages/pe/PurchaseEvaluationCreatePage.tsx
@@ -1,114 +1,18 @@
-// Create / edit draft phiếu Duyệt NCC (Header only — Suppliers + Details + Quotes
-// chỉnh sửa ở Detail tabs sau khi save).
-import { useEffect, useState } from 'react'
-import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
+// Page Create / edit header phiếu Duyệt NCC riêng (deep-link "Sửa header"
+// button trong PeDetailTabs). Refactor 2026-05-07: wrap PeHeaderForm cho DRY
+// + auto support manual budget (Mig 17). NCC + Báo giá + Items vẫn chỉnh ở
+// Detail tabs sau khi save.
import { useNavigate, useSearchParams } from 'react-router-dom'
-import { toast } from 'sonner'
import { ClipboardCheck } 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 {
- PurchaseEvaluationType,
- PurchaseEvaluationTypeLabel,
- type PeDetailBundle,
-} from '@/types/purchaseEvaluation'
-import { BudgetPhase, type BudgetListItem } from '@/types/budget'
-import type { Paged, Project } from '@/types/master'
+import { PeHeaderForm } from '@/components/pe/PeHeaderForm'
+import { PurchaseEvaluationType } from '@/types/purchaseEvaluation'
export function PurchaseEvaluationCreatePage() {
const navigate = useNavigate()
- const qc = useQueryClient()
const [sp] = useSearchParams()
const editId = sp.get('id')
const urlType = sp.get('type') ? Number(sp.get('type')) : PurchaseEvaluationType.DuyetNcc
- const projects = useQuery({
- queryKey: ['all-projects'],
- queryFn: async () => (await api.get<{ items: Project[] }>('/projects', { params: { pageSize: 1000 } })).data.items,
- })
- const existing = useQuery({
- queryKey: ['pe-detail', editId],
- queryFn: async () => (await api.get
(`/purchase-evaluations/${editId}`)).data,
- enabled: !!editId,
- })
-
- const [form, setForm] = useState({
- type: urlType as number,
- tenGoiThau: '',
- projectId: '',
- diaDiem: '',
- moTa: '',
- paymentTerms: '',
- budgetId: '' as string,
- })
-
- // Eligible Budgets: cùng Project + Phase=DaDuyet. BE filter trên Project +
- // Phase server-side để FE không phải lọc thêm.
- const eligibleBudgets = useQuery({
- queryKey: ['eligible-budgets', form.projectId],
- queryFn: async () => {
- const res = await api.get>('/budgets', {
- params: {
- pageSize: 100,
- projectId: form.projectId,
- phase: BudgetPhase.DaDuyet,
- },
- })
- return res.data.items
- },
- enabled: !!form.projectId,
- })
-
- useEffect(() => {
- if (existing.data) {
- setForm({
- type: existing.data.type,
- tenGoiThau: existing.data.tenGoiThau,
- projectId: existing.data.projectId,
- diaDiem: existing.data.diaDiem ?? '',
- moTa: existing.data.moTa ?? '',
- paymentTerms: existing.data.paymentTerms ?? '',
- budgetId: existing.data.budgetId ?? '',
- })
- }
- }, [existing.data])
-
- const mut = useMutation({
- mutationFn: async () => {
- if (editId) {
- return api.put(`/purchase-evaluations/${editId}`, {
- id: editId,
- tenGoiThau: form.tenGoiThau,
- diaDiem: form.diaDiem || null,
- moTa: form.moTa || null,
- paymentTerms: form.paymentTerms || null,
- budgetId: form.budgetId || null,
- })
- }
- return api.post<{ id: string }>('/purchase-evaluations', {
- type: form.type,
- tenGoiThau: form.tenGoiThau,
- projectId: form.projectId,
- diaDiem: form.diaDiem || null,
- moTa: form.moTa || null,
- paymentTerms: form.paymentTerms || null,
- budgetId: form.budgetId || null,
- })
- },
- onSuccess: res => {
- toast.success(editId ? 'Đã lưu.' : 'Đã tạo phiếu.')
- qc.invalidateQueries({ queryKey: ['pe-list'] })
- const id = editId ?? (res as { data: { id: string } }).data.id
- navigate(`/purchase-evaluations?id=${id}&type=${form.type}`)
- },
- onError: e => toast.error(getErrorMessage(e)),
- })
-
return (
@@ -118,104 +22,12 @@ export function PurchaseEvaluationCreatePage() {
-
-
- Loại quy trình
- setForm({ ...form, type: Number(e.target.value) })}
- >
- {Object.values(PurchaseEvaluationType).map(t => (
- {PurchaseEvaluationTypeLabel[t]}
- ))}
-
-
-
-
- Tên gói thầu *
- setForm({ ...form, tenGoiThau: e.target.value })}
- placeholder="vd Cung cấp bê tông"
- />
-
-
-
- Dự án *
- setForm({ ...form, projectId: e.target.value })}
- >
- -- Chọn --
- {projects.data?.map(p => (
- {p.code} — {p.name}
- ))}
-
-
-
-
-
Ngân sách (đối chiếu chi phí)
-
setForm({ ...form, budgetId: e.target.value })}
- >
- — (không link)
- {eligibleBudgets.data?.map(b => (
-
- {b.maNganSach ?? '—'} · {b.tenNganSach} · {b.tongNganSach.toLocaleString('vi-VN')} đ
-
- ))}
-
-
- {!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.'
- : 'Chỉ list ngân sách đã duyệt cùng dự án.'}
-
-
-
-
- Địa điểm
- setForm({ ...form, diaDiem: e.target.value })}
- placeholder="Lô K, KCN Lộc An - Bình Sơn..."
- />
-
-
-
- Mô tả
-
-
-
- Điều khoản thanh toán (JSON hoặc text)
-
-
-
- navigate(-1)}>Hủy
- mut.mutate()}
- disabled={!form.tenGoiThau || !form.projectId || mut.isPending}
- >
- {editId ? 'Lưu' : 'Tạo phiếu'}
-
-
-
+
navigate(`/purchase-evaluations?id=${id}&type=${type}`)}
+ onCancel={() => navigate(-1)}
+ />
)
}
diff --git a/fe-user/src/types/contracts.ts b/fe-user/src/types/contracts.ts
index cbe68f4..9eb53e3 100644
--- a/fe-user/src/types/contracts.ts
+++ b/fe-user/src/types/contracts.ts
@@ -158,6 +158,9 @@ export type ContractDetail = {
updatedAt: string | null
budgetId: string | null
budget: ContractBudgetSummary | null
+ // Mig 17 — manual budget fallback khi không link Budget entity.
+ budgetManualName: string | null
+ budgetManualAmount: number | null
approvals: ContractApproval[]
comments: ContractComment[]
attachments: ContractAttachment[]
diff --git a/fe-user/src/types/purchaseEvaluation.ts b/fe-user/src/types/purchaseEvaluation.ts
index a81353e..7cec22c 100644
--- a/fe-user/src/types/purchaseEvaluation.ts
+++ b/fe-user/src/types/purchaseEvaluation.ts
@@ -252,6 +252,9 @@ export type PeDetailBundle = {
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
suppliers: PeSupplier[]
details: PeDetailRow[]
approvals: PeApproval[]