All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 3m4s
User feedback 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."
Implementation:
+ PeWorkspaceCreateView.tsx (~230 LOC, mirror fe-admin + fe-user)
- Sectioned card layout giống PeDetailTabs (5 section divider + title style)
- Section 1 "Thông tin gói thầu": editable inputs (Loại / Tên */Dự án */Địa
điểm/Mô tả/Payment) — 2-col grid responsive
- Section 2 "Chọn NCC/TP":
a. NCC chọn: text "(sau khi thêm NCC + chốt winner)" placeholder
b. Ngân sách: editable inline (toggle "Nhập tay" + Select OR 2 input —
giống BudgetFieldRow pattern)
c. Giá chào thầu: text "(auto-tính sau winner)" placeholder
d. Bản so sánh: LockedHint icon + text "Tải sau khi tạo"
- Section 3 "NCC tham gia (0)": LockedHint "Lưu phiếu trước → thêm NCC..."
- Section 4 "Hạng mục + Báo giá (0)": LockedHint
- Section 5 "Ý kiến 4 PB": amber banner "nhập khi duyệt"
- Action bar bottom: "Tạo phiếu" (disabled khi !tenGoiThau || !projectId)
+ Hủy
- POST /pe full payload (header + budget mode A or B). onSuccess: toast +
invalidate pe-list + onSaved(id, type) callback
~ PurchaseEvaluationWorkspacePage.tsx (× 2 app)
- Replace <PeHeaderForm> → <PeWorkspaceCreateView> trong mode='new'
- PeHeaderForm vẫn còn (dùng cho /new?id= deep-link "Sửa header" cũ)
Helpers duplicate trong PeWorkspaceCreateView (Section + FormRow + LockedHint)
để tránh circular import từ PeDetailTabs.
UAT mode rule applied (per memory feedback_uat_skip_verify): skip dotnet test
+ npm build verify, push ngay.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
144 lines
6.0 KiB
TypeScript
144 lines
6.0 KiB
TypeScript
// Workspace 2-panel cho leaf "Thao tác" Pe_*_Create (Type A=DuyetNcc / B=
|
|
// DuyetNccPhuongAn). Pattern mirror HĐ Thầu phụ ContractCreatePage:
|
|
// Panel 1 (320px): list pure picker (read-only, không edit/delete) + sticky
|
|
// "+ Thêm mới" bottom button (Q1 user 2026-05-07).
|
|
// Panel 2 (1fr): empty state · mode=new <PeHeaderForm> · else
|
|
// <PeDetailTabs mode="workspace"> (5 section + Section 5
|
|
// Ý kiến 4PB DISABLED — Q5: nhập ở leaf "Duyệt").
|
|
//
|
|
// URL: /purchase-evaluations/workspace?type={1|2}[&id=...][&mode=new][&q=][&phase=]
|
|
// Workflow Panel + Approvals + History KHÔNG render ở workspace (Q1 — chỉ
|
|
// hiện ở leaf Danh sách + Duyệt vẫn 3-panel).
|
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
|
import { useNavigate, useSearchParams } from 'react-router-dom'
|
|
import { toast } from 'sonner'
|
|
import { ClipboardCheck } from 'lucide-react'
|
|
import { EmptyState } from '@/components/EmptyState'
|
|
import { PeDetailTabs } from '@/components/pe/PeDetailTabs'
|
|
import { PeListPanel } from '@/components/pe/PeListPanel'
|
|
import { PeWorkspaceCreateView } from '@/components/pe/PeWorkspaceCreateView'
|
|
import { api } from '@/lib/api'
|
|
import { getErrorMessage } from '@/lib/apiError'
|
|
import {
|
|
PurchaseEvaluationType,
|
|
PurchaseEvaluationTypeLabel,
|
|
type PeDetailBundle,
|
|
} from '@/types/purchaseEvaluation'
|
|
|
|
export function PurchaseEvaluationWorkspacePage() {
|
|
const navigate = useNavigate()
|
|
const qc = useQueryClient()
|
|
const [sp, setSp] = useSearchParams()
|
|
const typeFilter = sp.get('type') ? Number(sp.get('type')) : PurchaseEvaluationType.DuyetNcc
|
|
const search = sp.get('q') ?? ''
|
|
const phase = sp.get('phase') ?? ''
|
|
const selectedId = sp.get('id')
|
|
const mode = sp.get('mode') // 'new' | null
|
|
const autoEditHeader = sp.get('editHeader') === '1'
|
|
|
|
const detail = useQuery({
|
|
queryKey: ['pe-detail', selectedId],
|
|
queryFn: async () => (await api.get<PeDetailBundle>(`/purchase-evaluations/${selectedId}`)).data,
|
|
enabled: !!selectedId,
|
|
})
|
|
|
|
const del = useMutation({
|
|
mutationFn: async (id: string) => api.delete(`/purchase-evaluations/${id}`),
|
|
onSuccess: () => {
|
|
toast.success('Đã xóa phiếu.')
|
|
setParams({ id: null })
|
|
qc.invalidateQueries({ queryKey: ['pe-list'] })
|
|
},
|
|
onError: e => toast.error(getErrorMessage(e)),
|
|
})
|
|
|
|
function setParams(updates: Record<string, string | null>) {
|
|
const next = new URLSearchParams(sp)
|
|
for (const [k, v] of Object.entries(updates)) {
|
|
if (v == null || v === '') next.delete(k)
|
|
else next.set(k, v)
|
|
}
|
|
// Search input gõ liên tục → replace (không spam history); pick/mode → push
|
|
const replace = Object.keys(updates).length === 1 && updates.q !== undefined
|
|
setSp(next, { replace })
|
|
}
|
|
|
|
const headerTitle = `${PurchaseEvaluationTypeLabel[typeFilter]} — Thao tác`
|
|
|
|
return (
|
|
<div className="flex h-[calc(100vh-4rem)] flex-col">
|
|
<header className="flex shrink-0 flex-wrap items-center justify-between gap-3 border-b border-slate-200 bg-white px-6 py-3">
|
|
<div className="flex items-center gap-2">
|
|
<ClipboardCheck className="h-5 w-5 text-slate-500" />
|
|
<h1 className="text-base font-semibold tracking-tight text-slate-900">{headerTitle}</h1>
|
|
</div>
|
|
<div className="text-[12px] text-slate-500">
|
|
Workspace 2-panel — Workflow + Duyệt ở menu “Duyệt”.
|
|
</div>
|
|
</header>
|
|
|
|
<div className="grid flex-1 grid-cols-1 overflow-hidden lg:grid-cols-[320px_1fr]">
|
|
{/* Panel 1: List pure picker + sticky create + pencil edit hover */}
|
|
<PeListPanel
|
|
typeFilter={typeFilter}
|
|
selectedId={selectedId}
|
|
search={search}
|
|
phase={phase}
|
|
onSelect={id => setParams({ id, mode: null, editHeader: null })}
|
|
onSearchChange={q => setParams({ q })}
|
|
onPhaseChange={p => setParams({ phase: p })}
|
|
showCreateButton
|
|
onCreate={() => setParams({ mode: 'new', id: null, editHeader: null })}
|
|
onEditClick={id => setParams({ id, mode: null, editHeader: '1' })}
|
|
/>
|
|
|
|
{/* Panel 2: Empty | Header form | Detail tabs (workspace mode) */}
|
|
<main className="hidden overflow-y-auto bg-slate-50 p-6 lg:block">
|
|
{/* Empty: chưa pick + chưa create */}
|
|
{!selectedId && mode !== 'new' && (
|
|
<EmptyState
|
|
icon={ClipboardCheck}
|
|
title="Chọn phiếu hoặc tạo mới"
|
|
description='Chọn 1 phiếu ở danh sách trái để nhập liệu, hoặc bấm "+ Thêm mới" ở dưới.'
|
|
/>
|
|
)}
|
|
|
|
{/* Mode "new": sectioned create view (5 sections, 3-5 locked tới khi save) */}
|
|
{mode === 'new' && (
|
|
<PeWorkspaceCreateView
|
|
defaultType={typeFilter}
|
|
onSaved={(newId, t) => setParams({ id: newId, mode: null, type: String(t) })}
|
|
onCancel={() => setParams({ mode: null })}
|
|
/>
|
|
)}
|
|
|
|
{/* Mode "edit": detail tabs (workspace = no workflow + Section 5 disabled) */}
|
|
{selectedId && detail.isLoading && (
|
|
<div className="text-sm text-slate-500">Đang tải…</div>
|
|
)}
|
|
{selectedId && detail.data && (
|
|
<PeDetailTabs
|
|
evaluation={detail.data}
|
|
onBack={() => setParams({ id: null, editHeader: null })}
|
|
onDelete={() => del.mutate(detail.data!.id)}
|
|
mode="workspace"
|
|
autoEditHeader={autoEditHeader}
|
|
/>
|
|
)}
|
|
</main>
|
|
</div>
|
|
|
|
{/* Mobile fallback: nếu không lg, redirect về detail page */}
|
|
{selectedId && (
|
|
<div className="lg:hidden">
|
|
{/* Quick UX: tap row khi mobile sẽ navigate fullpage detail */}
|
|
<button
|
|
onClick={() => navigate(`/purchase-evaluations/${selectedId}`)}
|
|
className="hidden"
|
|
/>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|