[CLAUDE] FE-User: PE + HĐ toggle "Nhập tay" + 2 fields manual budget mirror fe-admin
Chunk 4/5 — mirror y hệt Chunk 3 sang fe-user (rule §3.9 duplicate có chủ đích).
Files:
~ fe-user/src/types/purchaseEvaluation.ts — PeDetailBundle +2 field
~ fe-user/src/types/contracts.ts — ContractDetail +2 field
~ fe-user/src/components/pe/PeHeaderForm.tsx (copy từ fe-admin)
~ fe-user/src/components/pe/PeDetailTabs.tsx — Section "b. Ngân sách"
fallback display khi !ev.budget + có manual data
~ fe-user/src/pages/pe/PurchaseEvaluationCreatePage.tsx (copy refactor wrap)
~ fe-user/src/pages/contracts/ContractCreatePage.tsx — toggle pattern cho
NewContractForm + EditContractForm (giống fe-admin)
Verify: npm run build fe-user pass · 1904 modules · 0 TS error.
Next: Chunk 5 docs + push.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@ -343,6 +343,17 @@ function ChonNccSection({ ev, readOnly = false }: { ev: PeDetailBundle; readOnly
|
|||||||
{' · '}{ev.budget.tenNganSach}
|
{' · '}{ev.budget.tenNganSach}
|
||||||
{' · '}<span className="text-slate-500">{ev.budget.tongNganSach.toLocaleString('vi-VN')} đ</span>
|
{' · '}<span className="text-slate-500">{ev.budget.tongNganSach.toLocaleString('vi-VN')} đ</span>
|
||||||
</a>
|
</a>
|
||||||
|
) : ev.budgetManualAmount != null || ev.budgetManualName ? (
|
||||||
|
// Mig 17 — manual budget fallback: hiển thị tên + số tiền nhập tay,
|
||||||
|
// không phải link vào /budgets/{id} (không có Budget entity).
|
||||||
|
<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">— (chưa link)</span>}
|
) : <span className="text-slate-400">— (chưa link)</span>}
|
||||||
/>
|
/>
|
||||||
<FormRow
|
<FormRow
|
||||||
|
|||||||
@ -53,6 +53,10 @@ export function PeHeaderForm({
|
|||||||
moTa: '',
|
moTa: '',
|
||||||
paymentTerms: '',
|
paymentTerms: '',
|
||||||
budgetId: '' as string,
|
budgetId: '' as string,
|
||||||
|
// Mig 17 — manual budget fallback (toggle "Nhập tay" thay vì link)
|
||||||
|
budgetManual: false,
|
||||||
|
budgetManualName: '',
|
||||||
|
budgetManualAmount: 0,
|
||||||
})
|
})
|
||||||
|
|
||||||
const eligibleBudgets = useQuery({
|
const eligibleBudgets = useQuery({
|
||||||
@ -68,6 +72,8 @@ export function PeHeaderForm({
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (existing.data) {
|
if (existing.data) {
|
||||||
|
const hasManual = existing.data.budgetManualName !== null
|
||||||
|
|| existing.data.budgetManualAmount !== null
|
||||||
setForm({
|
setForm({
|
||||||
type: existing.data.type,
|
type: existing.data.type,
|
||||||
tenGoiThau: existing.data.tenGoiThau,
|
tenGoiThau: existing.data.tenGoiThau,
|
||||||
@ -76,10 +82,27 @@ export function PeHeaderForm({
|
|||||||
moTa: existing.data.moTa ?? '',
|
moTa: existing.data.moTa ?? '',
|
||||||
paymentTerms: existing.data.paymentTerms ?? '',
|
paymentTerms: existing.data.paymentTerms ?? '',
|
||||||
budgetId: existing.data.budgetId ?? '',
|
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,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}, [existing.data])
|
}, [existing.data])
|
||||||
|
|
||||||
|
// Manual mode: clear budgetId, gửi manualName/Amount. Link mode: clear manual.
|
||||||
|
const payloadBudgetFields = form.budgetManual
|
||||||
|
? {
|
||||||
|
budgetId: null,
|
||||||
|
budgetManualName: form.budgetManualName || null,
|
||||||
|
budgetManualAmount: form.budgetManualAmount > 0 ? form.budgetManualAmount : null,
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
budgetId: form.budgetId || null,
|
||||||
|
budgetManualName: null,
|
||||||
|
budgetManualAmount: null,
|
||||||
|
}
|
||||||
|
|
||||||
const mut = useMutation({
|
const mut = useMutation({
|
||||||
mutationFn: async () => {
|
mutationFn: async () => {
|
||||||
if (editId) {
|
if (editId) {
|
||||||
@ -89,7 +112,7 @@ export function PeHeaderForm({
|
|||||||
diaDiem: form.diaDiem || null,
|
diaDiem: form.diaDiem || null,
|
||||||
moTa: form.moTa || null,
|
moTa: form.moTa || null,
|
||||||
paymentTerms: form.paymentTerms || null,
|
paymentTerms: form.paymentTerms || null,
|
||||||
budgetId: form.budgetId || null,
|
...payloadBudgetFields,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
return api.post<{ id: string }>('/purchase-evaluations', {
|
return api.post<{ id: string }>('/purchase-evaluations', {
|
||||||
@ -99,7 +122,7 @@ export function PeHeaderForm({
|
|||||||
diaDiem: form.diaDiem || null,
|
diaDiem: form.diaDiem || null,
|
||||||
moTa: form.moTa || null,
|
moTa: form.moTa || null,
|
||||||
paymentTerms: form.paymentTerms || null,
|
paymentTerms: form.paymentTerms || null,
|
||||||
budgetId: form.budgetId || null,
|
...payloadBudgetFields,
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
onSuccess: res => {
|
onSuccess: res => {
|
||||||
@ -161,7 +184,21 @@ export function PeHeaderForm({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<Label>Ngân sách (đối chiếu chi phí)</Label>
|
<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>
|
||||||
|
</div>
|
||||||
|
{!form.budgetManual ? (
|
||||||
|
<>
|
||||||
<Select
|
<Select
|
||||||
value={form.budgetId}
|
value={form.budgetId}
|
||||||
disabled={!form.projectId}
|
disabled={!form.projectId}
|
||||||
@ -178,9 +215,38 @@ export function PeHeaderForm({
|
|||||||
{!form.projectId
|
{!form.projectId
|
||||||
? 'Chọn dự án trước để xem ngân sách khả dụng.'
|
? 'Chọn dự án trước để xem ngân sách khả dụng.'
|
||||||
: eligibleBudgets.data && eligibleBudgets.data.length === 0
|
: eligibleBudgets.data && eligibleBudgets.data.length === 0
|
||||||
? 'Dự án này chưa có ngân sách đã duyệt.'
|
? '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.'}
|
: 'Chỉ list ngân sách đã duyệt cùng dự án.'}
|
||||||
</p>
|
</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={form.budgetManualName}
|
||||||
|
onChange={e => setForm({ ...form, budgetManualName: 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={form.budgetManualAmount || ''}
|
||||||
|
onChange={e => setForm({ ...form, budgetManualAmount: Number(e.target.value) })}
|
||||||
|
placeholder="1000000000"
|
||||||
|
/>
|
||||||
|
{form.budgetManualAmount > 0 && (
|
||||||
|
<p className="mt-1 text-[11px] text-slate-500">
|
||||||
|
≈ {form.budgetManualAmount.toLocaleString('vi-VN')} đ
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@ -305,6 +305,10 @@ function ContractHeaderForm({
|
|||||||
const [noiDung, setNoiDung] = useState('')
|
const [noiDung, setNoiDung] = useState('')
|
||||||
const [bypass, setBypass] = useState(false)
|
const [bypass, setBypass] = useState(false)
|
||||||
const [budgetId, setBudgetId] = useState('')
|
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
|
// Reset type về default khi typeFilter (parent prop) thay đổi
|
||||||
useEffect(() => { setType(defaultType) }, [defaultType])
|
useEffect(() => { setType(defaultType) }, [defaultType])
|
||||||
@ -334,6 +338,11 @@ function ContractHeaderForm({
|
|||||||
})
|
})
|
||||||
|
|
||||||
const qc = useQueryClient()
|
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({
|
const create = useMutation({
|
||||||
mutationFn: async () => {
|
mutationFn: async () => {
|
||||||
const res = await api.post<{ id: string }>('/contracts', {
|
const res = await api.post<{ id: string }>('/contracts', {
|
||||||
@ -347,7 +356,7 @@ function ContractHeaderForm({
|
|||||||
noiDung: noiDung || null,
|
noiDung: noiDung || null,
|
||||||
bypassProcurementAndCCM: bypass,
|
bypassProcurementAndCCM: bypass,
|
||||||
draftData: null,
|
draftData: null,
|
||||||
budgetId: budgetId || null,
|
...budgetPayload,
|
||||||
})
|
})
|
||||||
return res.data.id
|
return res.data.id
|
||||||
},
|
},
|
||||||
@ -387,7 +396,21 @@ function ContractHeaderForm({
|
|||||||
typeReadonly={false}
|
typeReadonly={false}
|
||||||
/>
|
/>
|
||||||
<div className="mt-4 space-y-1.5">
|
<div className="mt-4 space-y-1.5">
|
||||||
<Label>Ngân sách (đối chiếu chi phí)</Label>
|
<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"
|
||||||
|
/>
|
||||||
|
Nhập tay (không link)
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
{!budgetManual ? (
|
||||||
|
<>
|
||||||
<Select
|
<Select
|
||||||
value={budgetId}
|
value={budgetId}
|
||||||
disabled={!projectId}
|
disabled={!projectId}
|
||||||
@ -404,9 +427,38 @@ function ContractHeaderForm({
|
|||||||
{!projectId
|
{!projectId
|
||||||
? 'Chọn dự án trước để xem ngân sách khả dụng.'
|
? 'Chọn dự án trước để xem ngân sách khả dụng.'
|
||||||
: eligibleBudgets.data && eligibleBudgets.data.length === 0
|
: eligibleBudgets.data && eligibleBudgets.data.length === 0
|
||||||
? 'Dự án này chưa có ngân sách đã duyệt.'
|
? '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.'}
|
: 'Chỉ list ngân sách đã duyệt cùng dự án.'}
|
||||||
</p>
|
</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>
|
</div>
|
||||||
<div className="mt-4 flex justify-end gap-2">
|
<div className="mt-4 flex justify-end gap-2">
|
||||||
<Button type="submit" disabled={create.isPending}>
|
<Button type="submit" disabled={create.isPending}>
|
||||||
@ -497,6 +549,11 @@ function ContractEditForm({
|
|||||||
const [tenHopDong, setTenHopDong] = useState(contract.tenHopDong ?? '')
|
const [tenHopDong, setTenHopDong] = useState(contract.tenHopDong ?? '')
|
||||||
const [noiDung, setNoiDung] = useState(contract.noiDung ?? '')
|
const [noiDung, setNoiDung] = useState(contract.noiDung ?? '')
|
||||||
const [budgetId, setBudgetId] = useState(contract.budgetId ?? '')
|
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({
|
const templates = useQuery({
|
||||||
queryKey: ['templates-by-type', contract.type],
|
queryKey: ['templates-by-type', contract.type],
|
||||||
@ -513,6 +570,10 @@ function ContractEditForm({
|
|||||||
})
|
})
|
||||||
|
|
||||||
const qc = useQueryClient()
|
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({
|
const update = useMutation({
|
||||||
mutationFn: async () => {
|
mutationFn: async () => {
|
||||||
await api.put(`/contracts/${contract.id}`, {
|
await api.put(`/contracts/${contract.id}`, {
|
||||||
@ -522,7 +583,7 @@ function ContractEditForm({
|
|||||||
noiDung: noiDung || null,
|
noiDung: noiDung || null,
|
||||||
templateId: templateId || null,
|
templateId: templateId || null,
|
||||||
draftData: null,
|
draftData: null,
|
||||||
budgetId: budgetId || null,
|
...budgetPayload,
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
@ -611,8 +672,22 @@ function ContractEditForm({
|
|||||||
<Textarea rows={4} value={noiDung} onChange={e => setNoiDung(e.target.value)} disabled={!isDraft} />
|
<Textarea rows={4} value={noiDung} onChange={e => setNoiDung(e.target.value)} disabled={!isDraft} />
|
||||||
</div>
|
</div>
|
||||||
<div className="col-span-2 space-y-1.5">
|
<div className="col-span-2 space-y-1.5">
|
||||||
<Label>Ngân sách (đối chiếu chi phí)</Label>
|
<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>
|
||||||
{isDraft ? (
|
{isDraft ? (
|
||||||
|
!budgetManual ? (
|
||||||
<>
|
<>
|
||||||
<Select value={budgetId} onChange={e => setBudgetId(e.target.value)}>
|
<Select value={budgetId} onChange={e => setBudgetId(e.target.value)}>
|
||||||
<option value="">— (không link)</option>
|
<option value="">— (không link)</option>
|
||||||
@ -624,10 +699,38 @@ function ContractEditForm({
|
|||||||
</Select>
|
</Select>
|
||||||
<p className="text-[11px] text-slate-500">
|
<p className="text-[11px] text-slate-500">
|
||||||
{eligibleBudgets.data && eligibleBudgets.data.length === 0
|
{eligibleBudgets.data && eligibleBudgets.data.length === 0
|
||||||
? 'Dự án này chưa có ngân sách đã duyệt.'
|
? '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.'}
|
: 'Chỉ list ngân sách đã duyệt cùng dự án.'}
|
||||||
</p>
|
</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>
|
||||||
|
)
|
||||||
) : contract.budget ? (
|
) : contract.budget ? (
|
||||||
<a
|
<a
|
||||||
href={`/budgets?id=${contract.budget.id}`}
|
href={`/budgets?id=${contract.budget.id}`}
|
||||||
@ -637,6 +740,16 @@ function ContractEditForm({
|
|||||||
{' · '}{contract.budget.tenNganSach}
|
{' · '}{contract.budget.tenNganSach}
|
||||||
{' · '}<span className="text-slate-500">{contract.budget.tongNganSach.toLocaleString('vi-VN')} đ</span>
|
{' · '}<span className="text-slate-500">{contract.budget.tongNganSach.toLocaleString('vi-VN')} đ</span>
|
||||||
</a>
|
</a>
|
||||||
|
) : 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">
|
||||||
|
{contract.budgetManualName && <span>{contract.budgetManualName}</span>}
|
||||||
|
{contract.budgetManualName && contract.budgetManualAmount != null && ' · '}
|
||||||
|
{contract.budgetManualAmount != null && (
|
||||||
|
<span className="font-semibold text-slate-900">{contract.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>
|
||||||
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<Input value="(không link)" disabled className="bg-slate-50" />
|
<Input value="(không link)" disabled className="bg-slate-50" />
|
||||||
)}
|
)}
|
||||||
|
|||||||
@ -1,114 +1,18 @@
|
|||||||
// Create / edit draft phiếu Duyệt NCC (Header only — Suppliers + Details + Quotes
|
// Page Create / edit header phiếu Duyệt NCC riêng (deep-link "Sửa header"
|
||||||
// chỉnh sửa ở Detail tabs sau khi save).
|
// button trong PeDetailTabs). Refactor 2026-05-07: wrap PeHeaderForm cho DRY
|
||||||
import { useEffect, useState } from 'react'
|
// + auto support manual budget (Mig 17). NCC + Báo giá + Items vẫn chỉnh ở
|
||||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
// Detail tabs sau khi save.
|
||||||
import { useNavigate, useSearchParams } from 'react-router-dom'
|
import { useNavigate, useSearchParams } from 'react-router-dom'
|
||||||
import { toast } from 'sonner'
|
|
||||||
import { ClipboardCheck } from 'lucide-react'
|
import { ClipboardCheck } from 'lucide-react'
|
||||||
import { Button } from '@/components/ui/Button'
|
import { PeHeaderForm } from '@/components/pe/PeHeaderForm'
|
||||||
import { Input } from '@/components/ui/Input'
|
import { PurchaseEvaluationType } from '@/types/purchaseEvaluation'
|
||||||
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'
|
|
||||||
|
|
||||||
export function PurchaseEvaluationCreatePage() {
|
export function PurchaseEvaluationCreatePage() {
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const qc = useQueryClient()
|
|
||||||
const [sp] = useSearchParams()
|
const [sp] = useSearchParams()
|
||||||
const editId = sp.get('id')
|
const editId = sp.get('id')
|
||||||
const urlType = sp.get('type') ? Number(sp.get('type')) : PurchaseEvaluationType.DuyetNcc
|
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<PeDetailBundle>(`/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<Paged<BudgetListItem>>('/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 (
|
return (
|
||||||
<div className="space-y-4 p-6">
|
<div className="space-y-4 p-6">
|
||||||
<header className="flex items-center gap-2">
|
<header className="flex items-center gap-2">
|
||||||
@ -118,104 +22,12 @@ export function PurchaseEvaluationCreatePage() {
|
|||||||
</h1>
|
</h1>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div className="max-w-2xl space-y-4 rounded-lg border border-slate-200 bg-white p-6 shadow-sm">
|
<PeHeaderForm
|
||||||
<div>
|
editId={editId}
|
||||||
<Label>Loại quy trình</Label>
|
defaultType={urlType}
|
||||||
<Select
|
onSaved={(id, type) => navigate(`/purchase-evaluations?id=${id}&type=${type}`)}
|
||||||
value={form.type}
|
onCancel={() => navigate(-1)}
|
||||||
disabled={!!editId}
|
|
||||||
onChange={e => setForm({ ...form, type: Number(e.target.value) })}
|
|
||||||
>
|
|
||||||
{Object.values(PurchaseEvaluationType).map(t => (
|
|
||||||
<option key={t} value={t}>{PurchaseEvaluationTypeLabel[t]}</option>
|
|
||||||
))}
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<Label>Tên gói thầu *</Label>
|
|
||||||
<Input
|
|
||||||
value={form.tenGoiThau}
|
|
||||||
onChange={e => setForm({ ...form, tenGoiThau: e.target.value })}
|
|
||||||
placeholder="vd Cung cấp bê tông"
|
|
||||||
/>
|
/>
|
||||||
</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>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<Label>Ngân sách (đối chiếu chi phí)</Label>
|
|
||||||
<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.'
|
|
||||||
: 'Chỉ list ngân sách đã duyệt cùng dự án.'}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<Label>Địa điểm</Label>
|
|
||||||
<Input
|
|
||||||
value={form.diaDiem}
|
|
||||||
onChange={e => setForm({ ...form, diaDiem: e.target.value })}
|
|
||||||
placeholder="Lô K, KCN Lộc An - Bình Sơn..."
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<Label>Mô tả</Label>
|
|
||||||
<Textarea
|
|
||||||
rows={3}
|
|
||||||
value={form.moTa}
|
|
||||||
onChange={e => setForm({ ...form, moTa: e.target.value })}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<Label>Điều khoản thanh toán (JSON hoặc text)</Label>
|
|
||||||
<Textarea
|
|
||||||
rows={3}
|
|
||||||
value={form.paymentTerms}
|
|
||||||
onChange={e => setForm({ ...form, paymentTerms: e.target.value })}
|
|
||||||
placeholder='{"tamUng":"10%","thanhToanTam":"100% W.done","quyetToan":"Final Account","baoHanh":"5%"}'
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex justify-end gap-2">
|
|
||||||
<Button variant="ghost" onClick={() => navigate(-1)}>Hủy</Button>
|
|
||||||
<Button
|
|
||||||
onClick={() => mut.mutate()}
|
|
||||||
disabled={!form.tenGoiThau || !form.projectId || mut.isPending}
|
|
||||||
>
|
|
||||||
{editId ? 'Lưu' : 'Tạo phiếu'}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -158,6 +158,9 @@ export type ContractDetail = {
|
|||||||
updatedAt: string | null
|
updatedAt: string | null
|
||||||
budgetId: string | null
|
budgetId: string | null
|
||||||
budget: ContractBudgetSummary | null
|
budget: ContractBudgetSummary | null
|
||||||
|
// Mig 17 — manual budget fallback khi không link Budget entity.
|
||||||
|
budgetManualName: string | null
|
||||||
|
budgetManualAmount: number | null
|
||||||
approvals: ContractApproval[]
|
approvals: ContractApproval[]
|
||||||
comments: ContractComment[]
|
comments: ContractComment[]
|
||||||
attachments: ContractAttachment[]
|
attachments: ContractAttachment[]
|
||||||
|
|||||||
@ -252,6 +252,9 @@ export type PeDetailBundle = {
|
|||||||
updatedAt: string | null
|
updatedAt: string | null
|
||||||
budgetId: string | null
|
budgetId: string | null
|
||||||
budget: BudgetSummary | 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[]
|
suppliers: PeSupplier[]
|
||||||
details: PeDetailRow[]
|
details: PeDetailRow[]
|
||||||
approvals: PeApproval[]
|
approvals: PeApproval[]
|
||||||
|
|||||||
Reference in New Issue
Block a user