All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 4m31s
- Mig 50 ReplaceBudgetModuleWithPeWorkItemBudgets: bang moi PeWorkItemBudgets (1 record/cap Du an x Hang muc, UNIQUE filtered [IsDeleted]=0) + drop 5 bang Budget cu + PE/Contracts drop BudgetId + backfill BudgetManualAmount->BudgetPeriodAmount TRUOC DropColumn (phieu UAT giu so) + DELETE menu/permission Bg_* IN-list children-first
- BE: PUT {id}/budget/pro (role Procurement) + {id}/budget/ccm (role CostControl, Adjustment cho phep AM) fail-closed Forbidden-truoc-side-effect + EnsureTrackedAsync race-safe (catch unique -> re-fetch winner, loi khac rethrow) + auto-create record khi tao phieu + budgetSummary DTO (luy ke trinh-truoc/chon-thau-truoc/de-xuat-ky-nay + full fallback du-tru-PRO + canEdit flags) + submit-guard (3) doi predicate BudgetPeriodAmount -> "chua nhap Ngan sach ky nay" + PATCH budget-adjust absolute-set 2 field moi + Contract GIU BudgetManual* (HD nhap tay khong doi) + ke thua HD map BudgetPeriodAmount
- FE x2 app SHA256 identical: bang "TONG HOP NGAN SACH TRINH KY" block A (full dam + ban hanh + V0 hieu chinh + du tru PRO + ghi chu, editable theo canEditPro/canEditCcm) + block B 9 dong cong thuc Excel (5=1+3, 6=2+4, 7=full-5, 8 tu nhap default 7, 9=4+8) + to mau vuot ngan sach #C00000 / am do / red-soft row8>row7 + "Chua chon" khi count=0 + banner phieu chua gan Hang muc + o "Ngan sach ky nay" o create/header + XOA pages/components/types budgets + routes + menuKeys + Layout staticMap 4-place
- Tests: +22 PeWorkItemBudgetTests (auto-create x3, ensure/race x2, authz matrix PRO x5 + CCM x3, budgetSummary aggregates x5, adjust x4) - 14 BudgetPolicyTests xoa theo module - 1 test via-BudgetId -> 263 PASS (45 Domain + 218 Infra, 0 fail)
- database-agent advise adopted: khong FK vat ly PE/Contracts->Budgets (DropColumn khong can DropForeignKey) + DropIndex truoc DropColumn (SQL 5074) + IN-list thay LIKE Bg_% (underscore wildcard + miss root) + khong Serializable wrap (nested-tx conflict codegen)
- Reviewer PASS-with-minor 0 blocker (verdict-first survived); 2 minor da sua truoc commit (comment adjustMut absolute-set + dead key budgetId); note: F4 approver-edit-budget UI entry tam drafter-only, BE van cho approver scope - cho UAT anh Kiet
- Scaffold-bug caught: EF tu sinh RenameColumn BudgetManualAmount->ExpectedRemainingAmount (SAI semantics) -> thay bang Add+UPDATE+Drop
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
264 lines
10 KiB
TypeScript
264 lines
10 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 type { 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: '',
|
||
// [S61 Mig 50] "Ngân sách - kỳ này" — thay budgetId/budgetManual* cũ (module
|
||
// Budget xóa hẳn; bảng Tổng hợp ngân sách gói thầu nằm ở PeDetailTabs).
|
||
budgetPeriodAmount: 0,
|
||
})
|
||
|
||
useEffect(() => {
|
||
if (existing.data) {
|
||
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 ?? '',
|
||
budgetPeriodAmount: existing.data.budgetPeriodAmount ?? 0,
|
||
})
|
||
}
|
||
}, [existing.data])
|
||
|
||
// [S61] PUT UpdateDraft null-safe: budgetPeriodAmount null = GIỮ giá trị cũ
|
||
// BE-side; gửi số > 0 mới set. (Clear hẳn → dùng bảng Tổng hợp/budget-adjust.)
|
||
const payloadBudgetFields = {
|
||
budgetPeriodAmount: form.budgetPeriodAmount > 0 ? form.budgetPeriodAmount : 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>
|
||
{/* [S61 Mig 50] Ô đơn "Ngân sách kỳ này" — thay picker Budget cũ + toggle
|
||
nhập tay. Số phân bổ cho RIÊNG phiếu này (row 3 bảng Tổng hợp). */}
|
||
<Label>Ngân sách kỳ này</Label>
|
||
<div className="relative max-w-xs">
|
||
<Input
|
||
type="text"
|
||
inputMode="numeric"
|
||
value={formatVndInput(form.budgetPeriodAmount)}
|
||
onChange={e => setForm({ ...form, budgetPeriodAmount: 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">
|
||
Số phân bổ cho riêng phiếu này — bắt buộc trước khi gửi duyệt. Ngân sách full gói thầu xem ở bảng "Tổng hợp ngân sách trình ký" trong phiếu.
|
||
</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>
|
||
|
||
{/* S59 vòng 5: field "Điều khoản thanh toán" GỠ khỏi form (anh chốt). State
|
||
paymentTerms giữ — load từ phiếu cũ + save giữ nguyên, data cũ KHÔNG mất. */}
|
||
|
||
<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>
|
||
)
|
||
}
|