Files
solution-erp/fe-admin/src/components/pe/PeHeaderForm.tsx

321 lines
12 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, 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 { 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,
})
// S57bis — list Hạng mục công việc (active only, mirror PeWorkspaceCreateView).
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),
})
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) {
const hasManual = 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: form.budgetManualName || null,
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>
<Select
value={form.workItemId}
onChange={e => {
const id = e.target.value
const w = workItems.data?.find(x => x.id === id)
setForm({ ...form, workItemId: id, tenGoiThau: id ? (w?.name ?? '') : (editId ? form.tenGoiThau : '') })
}}
>
<option value="">
{editId && form.tenGoiThau && !form.workItemId
? `Giữ nguyên: ${form.tenGoiThau}`
: '-- Chọn hạng mục công việc --'}
</option>
{workItems.data?.map(w => (
<option key={w.id} value={w.id}>
{w.category ? `[${w.category}] ` : ''}{w.code} {w.name}
</option>
))}
</Select>
</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>
<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> 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>
)
}