→ /system/pe-workflows/
diff --git a/fe-admin/src/components/pe/PeDetailTabs.tsx b/fe-admin/src/components/pe/PeDetailTabs.tsx
index ec81f72..1e81ed9 100644
--- a/fe-admin/src/components/pe/PeDetailTabs.tsx
+++ b/fe-admin/src/components/pe/PeDetailTabs.tsx
@@ -38,20 +38,32 @@ const fmtMoney = (v: number) => v.toLocaleString('vi-VN')
// Main detail content — flat render 3 section không tabs.
// Tên giữ PeDetailTabs để không break callsite (rename gây churn).
+//
+// `mode` (2026-05-07):
+// - 'detail' (default): full UX — Section 5 Ý kiến 4PB editable theo readOnly.
+// Dùng ở leaf "Danh sách" + "Duyệt" (3-panel pages).
+// - 'workspace': dùng ở leaf "Thao tác" (2-panel workspace). Section 5 LUÔN
+// disabled (Q5 user — ý kiến nhập khi duyệt, không phải workspace nhập liệu).
+// Workflow Panel + Approvals + History KHÔNG render trong PeDetailTabs (luôn
+// ở caller PeWorkflowPanel — workspace caller skip render Panel 3 hoàn toàn).
export function PeDetailTabs({
evaluation,
onBack,
onDelete,
readOnly = false,
+ mode = 'detail',
}: {
evaluation: PeDetailBundle
onBack: () => void
onDelete: () => void
/** Menu "Duyệt" (pendingMe=1) — ẩn mọi action thêm/sửa/xóa, chỉ xem + duyệt phase. */
readOnly?: boolean
+ /** 'workspace' = Section 5 LUÔN disabled (ý kiến nhập ở leaf Duyệt). */
+ mode?: 'detail' | 'workspace'
}) {
const navigate = useNavigate()
const isDraft = evaluation.phase === PurchaseEvaluationPhase.DangSoanThao
+ const opinionsReadOnly = readOnly || mode === 'workspace'
return (
@@ -112,7 +124,12 @@ export function PeDetailTabs({
-
+ {mode === 'workspace' && (
+
+ Ý kiến + chữ ký nhập khi duyệt phiếu — vào menu “Duyệt” để ký.
+
+ )}
+
diff --git a/fe-admin/src/components/pe/PeHeaderForm.tsx b/fe-admin/src/components/pe/PeHeaderForm.tsx
new file mode 100644
index 0000000..62312cd
--- /dev/null
+++ b/fe-admin/src/components/pe/PeHeaderForm.tsx
@@ -0,0 +1,223 @@
+// Header form cho phiếu Duyệt NCC — tách từ PurchaseEvaluationCreatePage để
+// reuse trong Workspace mode "new". Sửa header sau khi tạo vẫn redirect về
+// page Create cũ (`/purchase-evaluations/new?id=`) — workspace KHÔNG re-edit
+// header.
+import { useEffect, 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 { Select } from '@/components/ui/Select'
+import { Textarea } from '@/components/ui/Textarea'
+import { api } from '@/lib/api'
+import { getErrorMessage } from '@/lib/apiError'
+import {
+ PurchaseEvaluationType,
+ PurchaseEvaluationTypeLabel,
+ type PeDetailBundle,
+} from '@/types/purchaseEvaluation'
+import { BudgetPhase, type BudgetListItem } from '@/types/budget'
+import type { Paged, Project } from '@/types/master'
+
+export function PeHeaderForm({
+ editId,
+ defaultType,
+ onSaved,
+ onCancel,
+}: {
+ editId?: string | null
+ defaultType?: number
+ /** Gọi sau khi save thành công với (newId, type). Caller decide navigation. */
+ onSaved: (id: string, type: number) => void
+ onCancel?: () => void
+}) {
+ const qc = useQueryClient()
+ const initialType = defaultType ?? PurchaseEvaluationType.DuyetNcc
+
+ const projects = useQuery({
+ queryKey: ['all-projects'],
+ queryFn: async () => (await api.get<{ items: Project[] }>('/projects', { params: { pageSize: 1000 } })).data.items,
+ })
+ const existing = useQuery({
+ queryKey: ['pe-detail', editId],
+ queryFn: async () => (await api.get(`/purchase-evaluations/${editId}`)).data,
+ enabled: !!editId,
+ })
+
+ const [form, setForm] = useState({
+ type: initialType as number,
+ tenGoiThau: '',
+ projectId: '',
+ diaDiem: '',
+ moTa: '',
+ paymentTerms: '',
+ budgetId: '' as string,
+ })
+
+ const eligibleBudgets = useQuery({
+ queryKey: ['eligible-budgets', form.projectId],
+ queryFn: async () => {
+ const res = await api.get>('/budgets', {
+ params: { pageSize: 100, projectId: form.projectId, phase: BudgetPhase.DaDuyet },
+ })
+ return res.data.items
+ },
+ enabled: !!form.projectId,
+ })
+
+ useEffect(() => {
+ if (existing.data) {
+ setForm({
+ type: existing.data.type,
+ tenGoiThau: existing.data.tenGoiThau,
+ projectId: existing.data.projectId,
+ diaDiem: existing.data.diaDiem ?? '',
+ moTa: existing.data.moTa ?? '',
+ paymentTerms: existing.data.paymentTerms ?? '',
+ budgetId: existing.data.budgetId ?? '',
+ })
+ }
+ }, [existing.data])
+
+ const mut = useMutation({
+ mutationFn: async () => {
+ if (editId) {
+ return api.put(`/purchase-evaluations/${editId}`, {
+ id: editId,
+ tenGoiThau: form.tenGoiThau,
+ diaDiem: form.diaDiem || null,
+ moTa: form.moTa || null,
+ paymentTerms: form.paymentTerms || null,
+ budgetId: form.budgetId || null,
+ })
+ }
+ return api.post<{ id: string }>('/purchase-evaluations', {
+ type: form.type,
+ tenGoiThau: form.tenGoiThau,
+ projectId: form.projectId,
+ diaDiem: form.diaDiem || null,
+ moTa: form.moTa || null,
+ paymentTerms: form.paymentTerms || null,
+ budgetId: form.budgetId || null,
+ })
+ },
+ onSuccess: res => {
+ toast.success(editId ? 'Đã lưu.' : 'Đã tạo phiếu.')
+ qc.invalidateQueries({ queryKey: ['pe-list'] })
+ const id = editId ?? (res as { data: { id: string } }).data.id
+ onSaved(id, form.type)
+ },
+ onError: e => toast.error(getErrorMessage(e)),
+ })
+
+ return (
+
+
+
+ {editId ? 'Sửa header phiếu' : 'Tạo phiếu Duyệt NCC mới'}
+
+
+ {editId
+ ? 'Chỉ sửa các field thông tin chung — NCC + báo giá + ý kiến nhập ở Panel chi tiết.'
+ : 'Tạo header trước, sau đó nhập NCC + Báo giá + Hạng mục ở Panel chi tiết.'}
+
+
+
+
+
+
+
+
+
+
+ setForm({ ...form, tenGoiThau: e.target.value })}
+ placeholder="vd Cung cấp bê tông"
+ />
+
+
+
+
+
+
+
+
+
+
+
+ {!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.'
+ : 'Chỉ list ngân sách đã duyệt cùng dự án.'}
+
+
+
+
+
+ setForm({ ...form, diaDiem: e.target.value })}
+ placeholder="Lô K, KCN Lộc An - Bình Sơn..."
+ />
+
+
+
+
+
+
+
+
+
+
+
+ {onCancel && (
+
+ )}
+
+
+
+ )
+}
diff --git a/fe-admin/src/components/pe/PeListPanel.tsx b/fe-admin/src/components/pe/PeListPanel.tsx
new file mode 100644
index 0000000..9321f82
--- /dev/null
+++ b/fe-admin/src/components/pe/PeListPanel.tsx
@@ -0,0 +1,184 @@
+// Pure picker panel cho workspace 2-panel "Thao tác" (Pe_*_Create leaf).
+// KHÔNG có inline Edit/Delete (per Q1 user 2026-05-07): chỉ click để pick, +
+// optional sticky bottom "+ Thêm mới" button khi showCreateButton=true.
+//
+// Reuse-able: caller quản URL state qua props (search/phase/typeFilter), panel
+// chỉ render + invoke callbacks. Pendingme vẫn truyền được nếu cần dùng cho
+// inbox view khác (hiện chỉ workspace dùng pendingMe=false).
+import { useQuery } from '@tanstack/react-query'
+import { ClipboardCheck, Plus, Search } from 'lucide-react'
+import { Button } from '@/components/ui/Button'
+import { Input } from '@/components/ui/Input'
+import { Select } from '@/components/ui/Select'
+import { EmptyState } from '@/components/EmptyState'
+import { SlaTimer } from '@/components/SlaTimer'
+import { api } from '@/lib/api'
+import { cn } from '@/lib/cn'
+import type { Paged } from '@/types/master'
+import {
+ PurchaseEvaluationPhase,
+ PurchaseEvaluationPhaseColor,
+ PurchaseEvaluationPhaseLabel,
+ PurchaseEvaluationTypeLabel,
+ type PeListItem,
+} from '@/types/purchaseEvaluation'
+
+export function PeListPanel({
+ typeFilter,
+ pendingMe = false,
+ selectedId,
+ search,
+ phase,
+ onSelect,
+ onSearchChange,
+ onPhaseChange,
+ showCreateButton = false,
+ onCreate,
+}: {
+ typeFilter: number | null
+ pendingMe?: boolean
+ selectedId: string | null
+ search: string
+ phase: string
+ onSelect: (id: string) => void
+ onSearchChange: (q: string) => void
+ onPhaseChange: (p: string) => void
+ showCreateButton?: boolean
+ onCreate?: () => void
+}) {
+ const list = useQuery({
+ queryKey: ['pe-list', { typeFilter, pendingMe, search, phase }],
+ queryFn: async () => {
+ if (pendingMe) {
+ const res = await api.get('/purchase-evaluations/inbox', {
+ params: { type: typeFilter ?? undefined },
+ })
+ return { items: res.data, total: res.data.length, page: 1, pageSize: res.data.length }
+ }
+ const res = await api.get>('/purchase-evaluations', {
+ params: {
+ pageSize: 50,
+ search: search || undefined,
+ type: typeFilter ?? undefined,
+ phase: phase || undefined,
+ },
+ })
+ return res.data
+ },
+ })
+
+ const rows = list.data?.items ?? []
+
+ return (
+
+ )
+}
diff --git a/fe-admin/src/pages/pe/PurchaseEvaluationWorkspacePage.tsx b/fe-admin/src/pages/pe/PurchaseEvaluationWorkspacePage.tsx
new file mode 100644
index 0000000..c7eddb6
--- /dev/null
+++ b/fe-admin/src/pages/pe/PurchaseEvaluationWorkspacePage.tsx
@@ -0,0 +1,140 @@
+// 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 · else
+// (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 { PeHeaderForm } from '@/components/pe/PeHeaderForm'
+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 detail = useQuery({
+ queryKey: ['pe-detail', selectedId],
+ queryFn: async () => (await api.get(`/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) {
+ 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 (
+
+
+
+
+ {headerTitle}
+
+
+ Workspace 2-panel — Workflow + Duyệt ở menu “Duyệt”.
+
+
+
+
+ {/* Panel 1: List pure picker + sticky create */}
+ setParams({ id, mode: null })}
+ onSearchChange={q => setParams({ q })}
+ onPhaseChange={p => setParams({ phase: p })}
+ showCreateButton
+ onCreate={() => setParams({ mode: 'new', id: null })}
+ />
+
+ {/* Panel 2: Empty | Header form | Detail tabs (workspace mode) */}
+
+ {/* Empty: chưa pick + chưa create */}
+ {!selectedId && mode !== 'new' && (
+
+ )}
+
+ {/* Mode "new": header form */}
+ {mode === 'new' && (
+ 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 && (
+ Đang tải…
+ )}
+ {selectedId && detail.data && (
+ setParams({ id: null })}
+ onDelete={() => del.mutate(detail.data!.id)}
+ mode="workspace"
+ />
+ )}
+
+
+
+ {/* Mobile fallback: nếu không lg, redirect về detail page */}
+ {selectedId && (
+
+ {/* Quick UX: tap row khi mobile sẽ navigate fullpage detail */}
+
+ )}
+
+ )
+}