All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 4m23s
- PeDetailTabs Section 5 Dieu chinh ngan sach: bo input "Ten (khong bat buoc)" (user khong hieu "y nghia du phong la gi") - manual budget chi con So tien (VND). State manualName drop, payload budgetManualName: null. Ten cu phieu truoc van hien read-only, ve null khi Luu dieu chinh lan toi. - PeHeaderForm: payload budgetManualName null + hasManual detect theo CA amount (phieu moi name=null sau khi bo o Ten -> van nhan dung manual mode). - PeWorkspaceCreateView: khong doi (chua tung co o Ten, payload '' || null = null san). - SHA256 mirror x2 app IDENTICAL, build tsc+vite x2 PASS.
334 lines
13 KiB
TypeScript
334 lines
13 KiB
TypeScript
// Header form cho phiếu Duyệt NCC — tách từ PurchaseEvaluationCreatePage để
|
||
// reuse trong Workspace mode "new". Sửa header sau khi tạo vẫn redirect về
|
||
// page Create cũ (`/purchase-evaluations/new?id=`) — workspace KHÔNG re-edit
|
||
// header.
|
||
import { useEffect, useRef, useState } from 'react'
|
||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||
import { toast } from 'sonner'
|
||
import { Button } from '@/components/ui/Button'
|
||
import { Input } from '@/components/ui/Input'
|
||
import { Label } from '@/components/ui/Label'
|
||
import { SearchableSelect } from '@/components/ui/SearchableSelect'
|
||
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'
|
||
|
||
// VND format helpers (mirror PeDetailTabs.tsx — session 20)
|
||
const parseVnd = (s: string): number => Number(s.replace(/[^\d]/g, '')) || 0
|
||
const formatVndInput = (n: number): string => (n > 0 ? n.toLocaleString('vi-VN') : '')
|
||
|
||
// S57bis — Hạng mục công việc (WorkItem master, mirror PeWorkspaceCreateView).
|
||
type WorkItemOption = {
|
||
id: string
|
||
code: string
|
||
name: string
|
||
category?: string | null
|
||
isActive?: boolean
|
||
}
|
||
|
||
export function PeHeaderForm({
|
||
editId,
|
||
defaultType,
|
||
onSaved,
|
||
onCancel,
|
||
}: {
|
||
editId?: string | null
|
||
defaultType?: number
|
||
/** Gọi sau khi save thành công với (newId, type). Caller decide navigation. */
|
||
onSaved: (id: string, type: number) => void
|
||
onCancel?: () => void
|
||
}) {
|
||
const qc = useQueryClient()
|
||
const initialType = defaultType ?? 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,
|
||
})
|
||
|
||
// S59 — track Địa điểm auto-fill gần nhất (mirror PeWorkspaceCreateView).
|
||
const lastAutoLoc = useRef('')
|
||
|
||
// S57bis — list Hạng mục công việc (active only, mirror PeWorkspaceCreateView).
|
||
// S59 — sort numeric-aware client (mã PMH không pad số — mirror CreateView).
|
||
const workItems = useQuery({
|
||
queryKey: ['catalogs', 'work-items'],
|
||
queryFn: async () => (await api.get<WorkItemOption[]>('/catalogs/work-items')).data,
|
||
select: rows => rows
|
||
.filter(r => r.isActive !== false)
|
||
.sort((a, b) => (a.category ?? '').localeCompare(b.category ?? '', 'vi')
|
||
|| a.code.localeCompare(b.code, 'vi', { numeric: true })),
|
||
})
|
||
|
||
const [form, setForm] = useState({
|
||
type: initialType as number,
|
||
tenGoiThau: '',
|
||
projectId: '',
|
||
workItemId: '',
|
||
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,
|
||
})
|
||
|
||
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,
|
||
projectId: existing.data.projectId,
|
||
workItemId: existing.data.workItemId ?? '', // S57bis — load để PUT gửi lại, tránh null-hóa
|
||
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,
|
||
})
|
||
}
|
||
}, [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,
|
||
}
|
||
|
||
const mut = useMutation({
|
||
mutationFn: async () => {
|
||
if (editId) {
|
||
return api.put(`/purchase-evaluations/${editId}`, {
|
||
id: editId,
|
||
tenGoiThau: form.tenGoiThau,
|
||
workItemId: form.workItemId || null, // S57bis — BE null-safe: null = giữ nguyên
|
||
diaDiem: form.diaDiem || null,
|
||
moTa: form.moTa || null,
|
||
paymentTerms: form.paymentTerms || null,
|
||
...payloadBudgetFields,
|
||
})
|
||
}
|
||
return api.post<{ id: string }>('/purchase-evaluations', {
|
||
type: form.type,
|
||
tenGoiThau: form.tenGoiThau,
|
||
projectId: form.projectId,
|
||
workItemId: form.workItemId || null, // S57bis — create require (BE validator NotEmpty)
|
||
diaDiem: form.diaDiem || null,
|
||
moTa: form.moTa || null,
|
||
paymentTerms: form.paymentTerms || null,
|
||
...payloadBudgetFields,
|
||
})
|
||
},
|
||
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
|
||
onSaved(id, form.type)
|
||
},
|
||
onError: e => toast.error(getErrorMessage(e)),
|
||
})
|
||
|
||
return (
|
||
<div className="max-w-2xl space-y-4 rounded-lg border border-slate-200 bg-white p-6 shadow-sm">
|
||
<header>
|
||
<h2 className="text-base font-semibold tracking-tight text-slate-900">
|
||
{editId ? 'Sửa header phiếu' : 'Tạo phiếu Duyệt NCC mới'}
|
||
</h2>
|
||
<p className="mt-0.5 text-[12px] text-slate-500">
|
||
{editId
|
||
? 'Chỉ sửa các field thông tin chung — NCC + báo giá + ý kiến nhập ở Panel chi tiết.'
|
||
: 'Tạo header trước, sau đó nhập NCC + Báo giá + Hạng mục ở Panel chi tiết.'}
|
||
</p>
|
||
</header>
|
||
|
||
<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>
|
||
{/* [S58] anh Kiệt (FDC) chốt 06-11: "Hạng mục công việc CHÍNH LÀ tên gói
|
||
thầu" → gộp 2 field S57bis thành 1 select: chọn hạng mục set cả
|
||
workItemId + tenGoiThau (= tên hạng mục). Phiếu cũ (workItemId null,
|
||
tên nhập tay): option đầu "Giữ nguyên" — không ép đổi, PUT null-safe. */}
|
||
<Label>Tên gói thầu (Hạng mục công việc) *</Label>
|
||
{/* S59 UAT "nên có lọc để tự đánh chữ" → SearchableSelect gõ-lọc bỏ dấu.
|
||
Phiếu cũ (workItemId null): placeholder "Giữ nguyên: …", clear (×) = về giữ-nguyên. */}
|
||
<SearchableSelect
|
||
options={(workItems.data ?? []).map(w => ({
|
||
value: w.id,
|
||
label: `${w.category ? `[${w.category}] ` : ''}${w.code} — ${w.name}`,
|
||
}))}
|
||
value={form.workItemId}
|
||
onChange={id => {
|
||
const w = workItems.data?.find(x => x.id === id)
|
||
setForm({ ...form, workItemId: id, tenGoiThau: id ? (w?.name ?? '') : (editId ? form.tenGoiThau : '') })
|
||
}}
|
||
placeholder={editId && form.tenGoiThau && !form.workItemId
|
||
? `Giữ nguyên: ${form.tenGoiThau}`
|
||
: '-- Chọn hạng mục công việc (gõ để lọc) --'}
|
||
/>
|
||
</div>
|
||
|
||
<div>
|
||
<Label>Dự án *</Label>
|
||
{/* S59 UAT: gõ-lọc + auto-fill Địa điểm từ Project.Location (mirror CreateView;
|
||
chỉ ăn khi tạo mới — edit disabled). Ghi đè khi user chưa gõ tay diaDiem. */}
|
||
<SearchableSelect
|
||
options={(projects.data ?? []).map(p => ({ value: p.id, label: `${p.code} — ${p.name}` }))}
|
||
value={form.projectId}
|
||
disabled={!!editId}
|
||
onChange={id => {
|
||
const p = projects.data?.find(x => x.id === id)
|
||
const loc = p?.location ?? ''
|
||
setForm(f => {
|
||
const untouched = !f.diaDiem || f.diaDiem === lastAutoLoc.current
|
||
return { ...f, projectId: id, diaDiem: untouched ? loc : f.diaDiem }
|
||
})
|
||
lastAutoLoc.current = loc
|
||
}}
|
||
placeholder="-- Chọn dự án (gõ để lọc) --"
|
||
/>
|
||
</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>
|
||
</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>
|
||
)}
|
||
</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">
|
||
{onCancel && (
|
||
<Button variant="ghost" onClick={onCancel}>Hủy</Button>
|
||
)}
|
||
<Button
|
||
onClick={() => mut.mutate()}
|
||
disabled={!form.tenGoiThau || !form.projectId || (!editId && !form.workItemId) || mut.isPending}
|
||
>
|
||
{editId ? 'Lưu' : 'Tạo phiếu'}
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|