Files
solution-erp/fe-admin/src/components/pe/PeWorkspaceCreateView.tsx
pqhuy1987 dd117b749c
All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 4m24s
[CLAUDE] PurchaseEvaluation: PE gắn Hạng mục công việc (Mig 49) + mở quyền Pe all-role + menu Cá nhân + khóa 14 demo user
Sếp chốt deadline 15:00 (Zalo 11:02-11:17): flow tạo phiếu chọn quy trình → dự án → HẠNG MỤC → NCC/TP; phiếu dạng «Dự án – Hạng mục»; all-user thấy Duyệt NCC + master config; clear data cũ.

- Mig 49 AddWorkItemToPurchaseEvaluation: PE.WorkItemId Guid? loose-Guid + index (KHÔNG FK vật lý — convention PE, database-agent design). Validator NotEmpty (create) + FK-guard AnyAsync(IsActive) → Conflict + UpdateDraft NULL-SAFE (client không gửi → giữ, chống null-hóa bug-class S42). 3 projection ListItemDto LEFT-join WorkItems.
- FE ×2 app: PeWorkspaceCreateView select «c. Hạng mục *» + PeHeaderForm (load existing + PUT gửi lại, SHA256 IDENTICAL) + PeDetailTabs (header «Dự án – Hạng mục» + FormRow + inline khóa) + types. Route reuse /catalogs/work-items.
- Perm: SeedAllRolesReviewReadPermissionsAsync extend Pe_* 11 key (factory — Pe leaf không nằm All) CanRead+CanCreate upgrade-only mọi role; PeWf_*/AwV2 GIỮ Admin. HRM/Office/Master/Catalogs CanRead (S57). Master write-lock Admin,CatalogManager ×3 controller.
- Menu «Cá nhân» (Personal root 30, mirror Puro) + Chấm công re-parent + HrmConfig→Master + parentBackfill idempotent + admin bỏ ẩn Master (đảo S29).
- LockDemoSampleUsersAsync: khóa 14/16 sample (GIỮ nv.cao+nv.truong IT-pool + catalog.manager) — ungated idempotent, IsActive=0+Lockout+SecurityStamp rotate.
- Tests +12 PeWorkItemGuardTests (validator/FK-guard/null-safe) → 240 PASS. npm ×2 + BE 0W/0E.
- Excel (3) đối chiếu: 62/71/3 identical S55 — no data change.
- Gate: em main evidence-checklist (2 reviewer-spawn die-0-byte — resume-kill; backstop 12 guard-test + authz-key/role-string/Mig-49 evidence-lệnh).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 12:13:26 +07:00

415 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).
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),
})
// Mig 23 — fetch list quy trình duyệt V2 cho User chọn (filter theo
// ApplicableType khớp với defaultType: 1=DuyetNcc / 2=DuyetNccPhuongAn).
// 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]}. Vào{' '}
<span className="font-mono">/system/approval-workflows-v2</span> đ tạo trước.
</p>
)}
</div>
<div className="md:col-span-2">
<Label className="text-[11px]">a. Tên gói thầu *</Label>
<Input
value={form.tenGoiThau}
onChange={e => setForm({ ...form, tenGoiThau: e.target.value })}
placeholder="vd Cung cấp bê tông"
/>
</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 className="md:col-span-2">
<Label className="text-[11px]">c. Hạng mục công việc *</Label>
<Select
value={form.workItemId}
onChange={e => setForm({ ...form, workItemId: e.target.value })}
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>
<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>
)
}