[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:
pqhuy1987
2026-05-07 12:40:29 +07:00
parent bab503189a
commit 14f8d9d808
6 changed files with 268 additions and 260 deletions

View File

@ -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

View File

@ -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,26 +184,69 @@ 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">
<Select <Label className="mb-0">Ngân sách (đi chiếu chi phí)</Label>
value={form.budgetId} {/* Toggle "Nhập tay" — Mig 17 fallback khi không link Budget entity */}
disabled={!form.projectId} <label className="inline-flex cursor-pointer items-center gap-1.5 text-[11px] text-slate-600">
onChange={e => setForm({ ...form, budgetId: e.target.value })} <input
> type="checkbox"
<option value=""> (không link)</option> checked={form.budgetManual}
{eligibleBudgets.data?.map(b => ( onChange={e => setForm({ ...form, budgetManual: e.target.checked })}
<option key={b.id} value={b.id}> className="h-3.5 w-3.5 rounded border-slate-300"
{b.maNganSach ?? '—'} · {b.tenNganSach} · {b.tongNganSach.toLocaleString('vi-VN')} đ />
</option> Nhập tay (không link)
))} </label>
</Select> </div>
<p className="mt-1 text-[11px] text-slate-500"> {!form.budgetManual ? (
{!form.projectId <>
? 'Chọn dự án trước để xem ngân sách khả dụng.' <Select
: eligibleBudgets.data && eligibleBudgets.data.length === 0 value={form.budgetId}
? 'Dự án này chưa có ngân sách đã duyệt.' disabled={!form.projectId}
: 'Chỉ list ngân sách đã duyệt cùng dự án.'} onChange={e => setForm({ ...form, budgetId: e.target.value })}
</p> >
<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 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>

View File

@ -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,26 +396,69 @@ 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">
<Select <Label className="mb-0">Ngân sách (đi chiếu chi phí)</Label>
value={budgetId} {/* Toggle "Nhập tay" — Mig 17 fallback khi không link Budget entity */}
disabled={!projectId} <label className="inline-flex cursor-pointer items-center gap-1.5 text-[11px] text-slate-600">
onChange={e => setBudgetId(e.target.value)} <input
> type="checkbox"
<option value=""> (không link)</option> checked={budgetManual}
{eligibleBudgets.data?.map(b => ( onChange={e => setBudgetManual(e.target.checked)}
<option key={b.id} value={b.id}> className="h-3.5 w-3.5 rounded border-slate-300"
{b.maNganSach ?? '—'} · {b.tenNganSach} · {b.tongNganSach.toLocaleString('vi-VN')} đ />
</option> Nhập tay (không link)
))} </label>
</Select> </div>
<p className="text-[11px] text-slate-500"> {!budgetManual ? (
{!projectId <>
? 'Chọn dự án trước để xem ngân sách khả dụng.' <Select
: eligibleBudgets.data && eligibleBudgets.data.length === 0 value={budgetId}
? 'Dự án này chưa có ngân sách đã duyệt.' disabled={!projectId}
: 'Chỉ list ngân sách đã duyệt cùng dự án.'} onChange={e => setBudgetId(e.target.value)}
</p> >
<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> </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,23 +672,65 @@ 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)}> <>
<option value=""> (không link)</option> <Select value={budgetId} onChange={e => setBudgetId(e.target.value)}>
{eligibleBudgets.data?.map(b => ( <option value=""> (không link)</option>
<option key={b.id} value={b.id}> {eligibleBudgets.data?.map(b => (
{b.maNganSach ?? '—'} · {b.tenNganSach} · {b.tongNganSach.toLocaleString('vi-VN')} đ <option key={b.id} value={b.id}>
</option> {b.maNganSach ?? '—'} · {b.tenNganSach} · {b.tongNganSach.toLocaleString('vi-VN')} đ
))} </option>
</Select> ))}
<p className="text-[11px] text-slate-500"> </Select>
{eligibleBudgets.data && eligibleBudgets.data.length === 0 <p className="text-[11px] text-slate-500">
? 'Dự án này chưa có ngân sách đã duyệt.' {eligibleBudgets.data && eligibleBudgets.data.length === 0
: 'Chỉ list ngân sách đã duyệt cùng dự án.'} ? '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.'
</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>
)
) : 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" />
)} )}

View File

@ -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>
<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> 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> </div>
) )
} }

View File

@ -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[]

View File

@ -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[]