Files
solution-erp/fe-user/src/components/pe/PeWorkspaceCreateView.tsx
pqhuy1987 6250f39c52 [CLAUDE] PurchaseEvaluation: hien ly do o Dia diem trong khi du an chua co Location
UAT anh Kiet FDC 07-28 "cho nay lay dia chi len luon giup anh". Do lai: co-che
auto-fill Dia diem tu Project.Location DA WIRE tu S59 va van chay dung (BE
ProjectFeatures.cs:51 project Location -> FE types master.ts:97 -> onChange
SearchableSelect). O trong vi DU AN THIEU DU LIEU: prod 57/62 du an co
Location NULL, gom ca BVN01 trong anh chup. Chi 5 du an co dia chi (CAL01,
MIDEA01, SAM01, TLB01, ZOTE01) - dung 5 dong co dia chi trong Excel S55.

=> Khong sua co-che (khong hong). Them dong canh bao amber khi da chon du an
ma du an do khong co Location: noi thang ly do + tro cho bo sung (Danh muc Du
an -> Dia diem), thay vi de o trong im lang trong nhu he thong hong.

4 file, SHA256 mirror fe-user/fe-admin giu IDENTICAL. npm build 2/2 PASS.
Backfill 57 dia chi con lai = can du lieu tu anh/FDC.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 09:32:33 +07:00

353 lines
17 KiB
TypeScript

// Create view cho workspace mode "new" — sectioned layout giống PeDetailTabs
// (5 section visible) nhưng trống hết. Section 1 + 2 editable (header + budget).
// Section 3-5 locked với placeholder "Lưu phiếu trước". Sau save → caller
// onSaved navigate sang ?id={newId} → workspace switch sang detail view (full
// PeDetailTabs với inline edit Section 1 + BudgetFieldRow + Suppliers/Items).
//
// Pattern user 2026-05-07: "Thêm mới list ra hết trường dữ liệu giống chỉnh
// sửa nhưng trống, mở rộng từng phần. Save header xong mới cho nhập chi tiết."
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 { api } from '@/lib/api'
import { getErrorMessage } from '@/lib/apiError'
import { PurchaseEvaluationTypeLabel } 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). Reuse catalog endpoint
// /catalogs/work-items (cùng query key CatalogsPage + ContractDetailsTab dùng).
type WorkItemOption = {
id: string
code: string
name: string
category?: string | null
isActive?: boolean
}
// S59 UAT vòng 5 (anh chốt "điều khoản thanh toán bỏ nốt ra luôn tất cả các form"):
// field Điều khoản TT phiếu-level GỠ khỏi mọi form (Create/HeaderForm/inline-edit).
// PAYMENT_PRESETS + PAYMENT_CUSTOM drop. Cột "Điều khoản TT" per-NCC trong bảng
// so sánh GIỮ (data của từng NCC, khác field). Phiếu cũ đã nhập → display read-only.
export function PeWorkspaceCreateView({
defaultType,
onSaved,
onCancel,
}: {
defaultType: number
/** Callback sau khi POST thành công với (newId, type). Caller navigate. */
onSaved: (id: string, type: number) => void
onCancel?: () => void
}) {
const qc = useQueryClient()
const [form, setForm] = useState({
type: defaultType,
tenGoiThau: '',
projectId: '',
workItemId: '',
diaDiem: '',
moTa: '',
paymentTerms: '',
// anh Kiệt FDC — link thư mục hồ sơ trên NAS (1 cột HoSoLink, JSON hoSoLink).
hoSoLink: '',
// [S61 Mig 50] "Ngân sách - kỳ này" — thay budgetId/budgetManual* (module
// Budget cũ xóa hẳn; bảng Tổng hợp ngân sách gói thầu ở PeDetailTabs).
budgetPeriodAmount: 0,
// Mig 23 — Pin quy trình duyệt V2 (User tự chọn lúc tạo)
approvalWorkflowId: '',
})
// Payment terms: select preset OR "Khác" → text input
const projects = useQuery({
queryKey: ['all-projects'],
queryFn: async () => (await api.get<{ items: Project[] }>('/projects', { params: { pageSize: 1000 } })).data.items,
})
// S59 — track giá trị Địa điểm auto-fill gần nhất (từ Project.Location) để biết
// user đã gõ tay chưa: diaDiem === lastAutoLoc ⟹ chưa đụng → đổi dự án ghi đè được.
const lastAutoLoc = useRef('')
// S57bis — list Hạng mục công việc (active only — filter client nếu BE trả isActive).
// S59 — sort numeric-aware client: mã PMH không pad số (MAT-1..16, MEP-SUB-1…)
// → BE OrderBy(Code) string xếp "MAT-10" trước "MAT-2"; re-sort {numeric:true}.
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 })),
})
// Mig 23 — fetch list quy trình duyệt V2 (filter ApplicableType khớp defaultType).
// Mig 25 — chỉ hiện workflows admin đã ghim "cho user chọn" (IsUserSelectable=true).
const approvalWorkflows = useQuery({
queryKey: ['approval-workflows-v2-active', defaultType],
queryFn: async () => {
const res = await api.get<{ types: { applicableType: number; history: { id: string; code: string; version: number; name: string; isActive: boolean; isUserSelectable: boolean }[] }[] }>(
'/approval-workflows-v2',
{ params: { applicableType: defaultType } },
)
const typeBucket = res.data.types.find(t => t.applicableType === defaultType)
return (typeBucket?.history ?? []).filter(w => w.isUserSelectable)
},
})
// [D5 anh Kiệt FDC] Auto-chọn quy trình khi danh sách chỉ có 1 lựa chọn → hết nhầm
// "chưa chọn quy trình" (chỉ set khi user chưa tự chọn, giữ lựa chọn thủ công nếu có).
useEffect(() => {
const list = approvalWorkflows.data
if (list && list.length === 1 && !form.approvalWorkflowId) {
setForm(f => ({ ...f, approvalWorkflowId: list[0].id }))
}
}, [approvalWorkflows.data, form.approvalWorkflowId])
const budgetPayload = {
budgetPeriodAmount: form.budgetPeriodAmount > 0 ? form.budgetPeriodAmount : null,
}
const create = useMutation({
mutationFn: async () => {
const res = await api.post<{ id: string }>('/purchase-evaluations', {
type: form.type,
tenGoiThau: form.tenGoiThau,
projectId: form.projectId,
workItemId: form.workItemId || null,
diaDiem: form.diaDiem || null,
moTa: form.moTa || null,
paymentTerms: null, // S59 vòng 5: field gỡ khỏi form
hoSoLink: form.hoSoLink || null,
approvalWorkflowId: form.approvalWorkflowId || null,
...budgetPayload,
})
return res.data.id
},
onSuccess: id => {
toast.success('Đã tạo phiếu — mở chi tiết để thêm NCC + hạng mục.')
qc.invalidateQueries({ queryKey: ['pe-list'] })
onSaved(id, form.type)
},
onError: e => toast.error(getErrorMessage(e)),
})
// [D4/R3 anh Kiệt — "ràng mấy cái rủi ro lại hết"] Trường BẮT BUỘC để tạo phiếu — thiếu cái
// nào thì liệt kê cho user thấy (nút "Tạo phiếu" disabled + hint đỏ). KHÔNG chọn Quy trình
// duyệt → KHÔNG tạo được phiếu (anh chốt; defense-in-depth với BE validator NotEmpty S83).
const missingCreate = [
!form.approvalWorkflowId && 'Quy trình duyệt',
!form.workItemId && 'Hạng mục công việc (tên gói thầu)',
!form.projectId && 'Dự án',
].filter(Boolean) as string[]
const canSubmit = missingCreate.length === 0 && !!form.tenGoiThau && !create.isPending
return (
<div className="rounded-lg border border-slate-200 bg-white shadow-sm">
{/* Header bar */}
<div className="flex flex-wrap items-center justify-between gap-2 border-b border-slate-200 px-5 py-3">
<div>
<h2 className="text-base font-semibold text-slate-900">Tạo phiếu Duyệt NCC mới</h2>
<p className="mt-0.5 text-[12px] text-slate-500">
Nhập Section 1 + 2 bấm <strong>&ldquo;Tạo phiếu&rdquo;</strong> sau đó NCC / Hạng mục / Báo giá / Ý kiến mở khóa.
</p>
</div>
{onCancel && (
<Button variant="ghost" onClick={onCancel} className="text-xs"> Hủy</Button>
)}
</div>
<div className="divide-y divide-slate-200">
{/* Section 1 — Thông tin gói thầu (editable) */}
<Section title="1. Thông tin gói thầu">
<div className="grid gap-3 md:grid-cols-2">
<div className="md:col-span-2">
<Label className="text-[11px]">
Quy trình duyệt * <span className="text-[10px] font-normal text-slate-400">(theo {PurchaseEvaluationTypeLabel[form.type]})</span>
</Label>
<Select
value={form.approvalWorkflowId}
onChange={e => setForm({ ...form, approvalWorkflowId: e.target.value })}
required
>
<option value=""> Chọn quy trình duyệt </option>
{approvalWorkflows.data?.map(w => (
<option key={w.id} value={w.id}>
{w.code} v{String(w.version).padStart(2, '0')} {w.name}
{w.isActive ? ' (đang áp dụng)' : ''}
</option>
))}
</Select>
{approvalWorkflows.data && approvalWorkflows.data.length === 0 && (
<p className="mt-1 text-[11px] text-amber-700">
Chưa quy trình duyệt cho loại {PurchaseEvaluationTypeLabel[form.type]}. Liên hệ admin tạo trước.
</p>
)}
</div>
<div className="md:col-span-2">
{/* [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 field c (S57bis) vào a: chọn từ danh mục Hạng mục
thay nhập tay; chọn 1 phát set cả workItemId + tenGoiThau (= tên
hạng mục). Phiếu vẫn lưu cả 2 field BE — không đổi contract. */}
<Label className="text-[11px]">a. 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. */}
<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 ?? '') : '' })
}}
placeholder="— Chọn hạng mục công việc (gõ để lọc) —"
/>
{workItems.data && workItems.data.length === 0 && (
<p className="mt-1 text-[11px] text-amber-700">
Chưa hạng mục công việc nào. Vào Danh mục Hạng mục công việc đ tạo trước.
</p>
)}
</div>
<div className="md:col-span-2">
<Label className="text-[11px]">b. Dự án *</Label>
{/* S59 UAT "nên có tự gõ chữ" + "địa chỉ nên tự auto": chọn dự án tự điền
Địa điểm từ Project.Location (S55) — chỉ ghi đè khi user CHƯA gõ tay
(rỗng hoặc vẫn là giá trị auto của dự án trước, track qua lastAutoLoc). */}
<SearchableSelect
options={(projects.data ?? []).map(p => ({ value: p.id, label: `${p.code}${p.name}` }))}
value={form.projectId}
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>
<Label className="text-[11px]">Đa điểm</Label>
<Input
value={form.diaDiem}
onChange={e => setForm({ ...form, diaDiem: e.target.value })}
placeholder="Lô K, KCN Lộc An..."
/>
{/* [S157 UAT anh Kiệt 07-28 "lấy địa chỉ lên luôn"] auto-fill S59 vẫn chạy —
ô trống là do dự án CHƯA có Location trong Danh mục (đo prod: 57/62 thiếu).
Nói thẳng lý do + chỗ bổ sung, thay vì im lặng như hệ thống hỏng. */}
{!!form.projectId && !form.diaDiem
&& !projects.data?.find(p => p.id === form.projectId)?.location && (
<p className="mt-1 text-[11px] text-amber-700">
Dự án này chưa Đa điểm trong Danh mục Dự án nhập tay đây, hoặc bổ sung vào Danh mục (Dự án Đa điểm) đ các phiếu sau tự điền.
</p>
)}
</div>
<div>
<Label className="text-[11px]"> tả ngắn</Label>
<Input
value={form.moTa}
onChange={e => setForm({ ...form, moTa: e.target.value })}
placeholder="Phương án A: ..."
/>
</div>
</div>
</Section>
{/* Section 2 — Chọn NCC/TP (chỉ Ngân sách editable, còn lại sẽ unlock sau create) */}
<Section title="2. Chọn NCC / TP">
<div className="space-y-3">
{/* [S61 Mig 50] b. Ngân sách kỳ này — ô đơn thay picker Budget cũ +
toggle nhập tay (module Budget xóa hẳn). Số phân bổ cho RIÊNG
phiếu này (row 3 bảng "Tổng hợp ngân sách trình ký"). */}
<div className="flex gap-3">
<span className="w-44 shrink-0 pt-1.5 text-[12px] text-slate-500">b. Ngân sách kỳ này</span>
<div className="min-w-0 flex-1 space-y-1">
<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 text-sm"
/>
<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="text-[11px] text-slate-500">
Bắt buộc trước khi gửi duyệt. Ngân sách full gói thầu xem bảng &ldquo;Tổng hợp ngân sách trình &rdquo; sau khi tạo phiếu.
</p>
</div>
</div>
{/* [C1 anh Kiệt FDC] "c. Giá chào thầu" + "d. Bảng so sánh giá" ẨN khỏi form
TẠO (chưa dùng được lúc tạo) — vẫn hiện đầy đủ ở Detail tabs sau khi tạo phiếu. */}
{/* e. Link hồ sơ (anh Kiệt FDC) — dán link thư mục hồ sơ trên NAS công ty
(1 cột HoSoLink). Create = Input; khi xem phiếu render thẻ <a> bấm-mở. */}
<div className="flex gap-3">
<span className="w-44 shrink-0 pt-1.5 text-[12px] text-slate-500">e. Link hồ </span>
<div className="min-w-0 flex-1">
<Input
type="url"
value={form.hoSoLink}
onChange={e => setForm({ ...form, hoSoLink: e.target.value })}
placeholder="Dán link thư mục hồ sơ trên NAS..."
className="max-w-2xl text-sm"
/>
</div>
</div>
</div>
</Section>
{/* [D1 anh Kiệt FDC] Mục 3 (NCC tham gia) / 4 (Hạng mục + Báo giá) / 5 (Ý kiến) ẨN
khỏi form TẠO — chỉ là placeholder "Lưu phiếu trước", gây rối. Mở khóa đầy đủ
ở Detail tabs sau khi tạo phiếu. */}
</div>
{/* Action bar — [D4/R3 anh Kiệt] hint trái hiện RÕ còn thiếu gì (ràng rủi ro), nút phải. */}
<div className="flex items-center justify-between gap-3 border-t border-slate-200 bg-slate-50 px-5 py-3">
<div className="min-w-0 text-[11px]">
{missingCreate.length > 0 ? (
<span className="text-red-600"> Chưa đ đ tạo phiếu còn thiếu: <strong>{missingCreate.join(' · ')}</strong></span>
) : (
<span className="font-medium text-emerald-600"> Đ thông tin bắt buộc thể tạo phiếu</span>
)}
</div>
<div className="flex shrink-0 items-center gap-2">
{onCancel && (
<Button variant="ghost" onClick={onCancel} className="text-xs">Hủy</Button>
)}
<Button
onClick={() => create.mutate()}
disabled={!canSubmit}
>
{create.isPending ? 'Đang tạo…' : 'Tạo phiếu'}
</Button>
</div>
</div>
</div>
)
}
// Helper components — duplicate từ PeDetailTabs để tránh circular import.
function Section({ title, children }: { title: string; children: React.ReactNode }) {
return (
<section className="px-5 py-4">
<h3 className="mb-3 text-xs font-semibold uppercase tracking-wide text-slate-500">{title}</h3>
{children}
</section>
)
}