Files
solution-erp/fe-admin/src/components/pe/PeWorkspaceCreateView.tsx
pqhuy1987 c869d2617d
All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 4m33s
[CLAUDE] PurchaseEvaluation: rename 71 WorkItems theo format PMH anh Kiet FDC chot (MAT-n/SUB-n/MEP-SUB-n/MEP-EQU-n + ten "STT nhom ten") + FE sort numeric
- 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.
2026-06-11 17:14:06 +07:00

418 lines
18 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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>&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>
<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 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]"> 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ữ nhập khi duyệt phiếu vào menu &ldquo;Duyệt&rdquo; đ .
</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>
)
}