[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:
@ -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<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 (
|
||||
<div className="space-y-4 p-6">
|
||||
<header className="flex items-center gap-2">
|
||||
@ -118,104 +22,12 @@ export function PurchaseEvaluationCreatePage() {
|
||||
</h1>
|
||||
</header>
|
||||
|
||||
<div className="max-w-2xl space-y-4 rounded-lg border border-slate-200 bg-white p-6 shadow-sm">
|
||||
<div>
|
||||
<Label>Loại quy trình</Label>
|
||||
<Select
|
||||
value={form.type}
|
||||
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>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>
|
||||
<PeHeaderForm
|
||||
editId={editId}
|
||||
defaultType={urlType}
|
||||
onSaved={(id, type) => navigate(`/purchase-evaluations?id=${id}&type=${type}`)}
|
||||
onCancel={() => navigate(-1)}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user