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>
353 lines
17 KiB
TypeScript
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>“Tạo phiếu”</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 có 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 có 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 có Đị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]">Mô 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 “Tổng hợp ngân sách trình ký” 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ồ sơ</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 — 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>
|
|
)
|
|
}
|