All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 4m33s
- anh Kiet 16:59: "MA CV gom chu MEP-SUB-1 roi ten 1 MEP Sub MEP (Full) - dung kieu vay". - DbInitializer seed tuples 71 ma moi (VT->MAT, TP->SUB, MEP-0n->MEP-SUB-n, TB->MEP-EQU-n). - scripts/s59-rename-workitems-pmh.sql DA CHAY prod + LocalDB Dev TRUOC push (UPDATE giu Id, 71/71, OLD-CODES=0, verify JSON qua API prod tieng Viet nguyen ven). - FE x2 app (SHA256 mirror): PeWorkspaceCreateView + PeHeaderForm sort numeric-aware (ma khong pad -> string-sort loan "10"<"2") + tree Panel 1 workItemName numeric:true. - scripts/master-import-data.generated.md sync 71 dong W| + note mapping.
418 lines
18 KiB
TypeScript
418 lines
18 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 { useState } from 'react'
|
||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||
import { toast } from 'sonner'
|
||
import { Lock } from 'lucide-react'
|
||
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 { api } from '@/lib/api'
|
||
import { getErrorMessage } from '@/lib/apiError'
|
||
import { PurchaseEvaluationTypeLabel } 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). 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
|
||
}
|
||
|
||
// Preset điều khoản thanh toán phổ biến — user chọn 1 trong list, hoặc "Khác"
|
||
// để nhập tay. Save as plain text (không JSON như cũ — code-style không phù
|
||
// hợp UI cho end-user). User 2026-05-07 chỉnh.
|
||
const PAYMENT_PRESETS = [
|
||
'100% sau khi nghiệm thu',
|
||
'Tạm ứng 30% / Thanh toán 70% sau nghiệm thu',
|
||
'Tạm ứng 50% / Thanh toán 50% sau nghiệm thu',
|
||
'TGN-30 ngày (Thanh toán giao nhận 30 ngày)',
|
||
'TGN-45 ngày',
|
||
'TGN-60 ngày',
|
||
'Tiến độ theo từng đợt',
|
||
'Bảo hành 5% trong 12 tháng',
|
||
] as const
|
||
|
||
const PAYMENT_CUSTOM = '__custom__'
|
||
|
||
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: '',
|
||
budgetId: '',
|
||
// Mig 17 — manual budget fallback
|
||
budgetManual: false,
|
||
budgetManualName: '',
|
||
budgetManualAmount: 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 [paymentMode, setPaymentMode] = useState<string>('') // '' / preset / __custom__
|
||
const isPaymentCustom = paymentMode === PAYMENT_CUSTOM
|
||
|
||
const projects = useQuery({
|
||
queryKey: ['all-projects'],
|
||
queryFn: async () => (await api.get<{ items: Project[] }>('/projects', { params: { pageSize: 1000 } })).data.items,
|
||
})
|
||
|
||
// 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)
|
||
},
|
||
})
|
||
|
||
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,
|
||
})
|
||
|
||
const budgetPayload = form.budgetManual
|
||
? { budgetId: null, budgetManualName: form.budgetManualName || null, budgetManualAmount: form.budgetManualAmount > 0 ? form.budgetManualAmount : null }
|
||
: { budgetId: form.budgetId || null, budgetManualName: null, budgetManualAmount: 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: form.paymentTerms || 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)),
|
||
})
|
||
|
||
const canSubmit = !!form.tenGoiThau && !!form.projectId && !!form.workItemId && !!form.approvalWorkflowId && !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>
|
||
<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 ?? '') : '' })
|
||
}}
|
||
required
|
||
>
|
||
<option value="">— 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>
|
||
{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>
|
||
<Select
|
||
value={form.projectId}
|
||
onChange={e => setForm({ ...form, projectId: e.target.value, budgetId: '' })}
|
||
>
|
||
<option value="">— Chọn dự án —</option>
|
||
{projects.data?.map(p => (
|
||
<option key={p.id} value={p.id}>{p.code} — {p.name}</option>
|
||
))}
|
||
</Select>
|
||
</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..."
|
||
/>
|
||
</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 className="md:col-span-2">
|
||
<Label className="text-[11px]">Điều khoản thanh toán</Label>
|
||
<Select
|
||
value={paymentMode}
|
||
onChange={e => {
|
||
const v = e.target.value
|
||
setPaymentMode(v)
|
||
if (v === '' || v === PAYMENT_CUSTOM) {
|
||
setForm(f => ({ ...f, paymentTerms: '' }))
|
||
} else {
|
||
setForm(f => ({ ...f, paymentTerms: v }))
|
||
}
|
||
}}
|
||
>
|
||
<option value="">— Chọn điều khoản —</option>
|
||
{PAYMENT_PRESETS.map(p => (
|
||
<option key={p} value={p}>{p}</option>
|
||
))}
|
||
<option value={PAYMENT_CUSTOM}>Khác (nhập tay)</option>
|
||
</Select>
|
||
{isPaymentCustom && (
|
||
<Input
|
||
value={form.paymentTerms}
|
||
onChange={e => setForm({ ...form, paymentTerms: e.target.value })}
|
||
placeholder="Nhập điều khoản tùy chỉnh"
|
||
className="mt-2"
|
||
/>
|
||
)}
|
||
</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">
|
||
<FormRow
|
||
label="a. NCC / TP được chọn"
|
||
value={<span className="text-slate-400">— (sau khi thêm NCC tham gia + chốt winner)</span>}
|
||
/>
|
||
|
||
{/* b. Ngân sách — editable inline (Mig 17 toggle pattern) */}
|
||
<div className="flex gap-3">
|
||
<span className="w-44 shrink-0 pt-1.5 text-[12px] text-slate-500">b. Ngân sách</span>
|
||
<div className="min-w-0 flex-1 space-y-2">
|
||
<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>
|
||
{!form.budgetManual ? (
|
||
<>
|
||
<Select
|
||
value={form.budgetId}
|
||
disabled={!form.projectId}
|
||
onChange={e => setForm({ ...form, budgetId: e.target.value })}
|
||
className="text-sm"
|
||
>
|
||
<option value="">—</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="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 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 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>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
<FormRow
|
||
label="c. Giá chào thầu"
|
||
value={<span className="text-slate-400">— (auto-tính từ báo giá NCC sau khi chọn winner)</span>}
|
||
/>
|
||
<FormRow
|
||
label="d. Bản so sánh"
|
||
value={<LockedHint text="Tải bảng so sánh sau khi tạo phiếu." />}
|
||
/>
|
||
</div>
|
||
</Section>
|
||
|
||
{/* Section 3 — NCC tham gia (locked) */}
|
||
<Section title="3. NCC / TP tham gia (0)">
|
||
<LockedHint text="Lưu phiếu trước → thêm NCC + nhập thông tin liên hệ + chốt so sánh ở Detail tabs." />
|
||
</Section>
|
||
|
||
{/* Section 4 — Hạng mục + Báo giá (locked) */}
|
||
<Section title="4. Hạng mục + Báo giá (0)">
|
||
<LockedHint text="Lưu phiếu trước → thêm hạng mục + nhập báo giá ma trận NCC × Item ở Detail tabs." />
|
||
</Section>
|
||
|
||
{/* Section 5 — Ý kiến 4 phòng ban (locked + amber hint per Q5 user) */}
|
||
<Section title="5. Ý kiến 4 phòng ban (sign-off)">
|
||
<div className="rounded border border-amber-200 bg-amber-50 px-3 py-2 text-[12px] text-amber-800">
|
||
Ý kiến + chữ ký nhập khi duyệt phiếu — vào menu “Duyệt” để ký.
|
||
</div>
|
||
</Section>
|
||
</div>
|
||
|
||
{/* Action bar */}
|
||
<div className="flex items-center justify-end gap-2 border-t border-slate-200 bg-slate-50 px-5 py-3">
|
||
{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>
|
||
)
|
||
}
|
||
|
||
// 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>
|
||
)
|
||
}
|
||
|
||
function FormRow({ label, value }: { label: string; value: React.ReactNode }) {
|
||
return (
|
||
<div className="flex items-baseline gap-3 text-sm">
|
||
<span className="w-44 shrink-0 text-[12px] text-slate-500">{label}</span>
|
||
<div className="min-w-0 flex-1 text-slate-800">{value}</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
function LockedHint({ text }: { text: string }) {
|
||
return (
|
||
<div className="inline-flex items-center gap-1.5 rounded border border-dashed border-slate-300 bg-slate-50 px-3 py-1.5 text-[12px] text-slate-500">
|
||
<Lock className="h-3 w-3" />
|
||
{text}
|
||
</div>
|
||
)
|
||
}
|