From ee0d3608e79ee4c831ac3ef8769ec6a50ccea956 Mon Sep 17 00:00:00 2001 From: pqhuy1987 Date: Thu, 7 May 2026 10:36:06 +0700 Subject: [PATCH] =?UTF-8?q?[CLAUDE]=20FE-Admin:=20PE=20Thao=20t=C3=A1c=202?= =?UTF-8?q?-panel=20workspace=20+=20Panel=201=20read-only=20picker=20+=20S?= =?UTF-8?q?ection=205=20disabled?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Chunk 1/3 — restructure leaf "Thao tác" (Pe_*_Create) từ page tạo header riêng sang workspace 2-panel mirror pattern HĐ Thầu phụ ContractCreatePage: Panel 1 (320px): list pure picker (KHÔNG inline edit/delete per Q1 user) + sticky "+ Thêm mới" bottom button. Panel 2 (1fr): empty state | mode=new | (5 section, Section 5 Ý kiến 4PB DISABLED per Q5 user — nhập ở leaf "Duyệt"). Workflow Panel + Approvals + History KHÔNG render trong workspace (Q1) — chỉ hiện ở leaf "Danh sách" + "Duyệt" giữ nguyên 3-panel hiện tại (Q3). URL: /purchase-evaluations/workspace?type={1|2}[&id=...][&mode=new][&q=][&phase=] Menu resolver Pe_*_Create: /purchase-evaluations/new?type=N → /workspace?type=N. Route mới /workspace; route /new giữ tồn tại cho deep-link "Sửa header" button. Files: + fe-admin/src/components/pe/PeListPanel.tsx (~180 LOC) — pure picker reuseable + fe-admin/src/components/pe/PeHeaderForm.tsx (~210 LOC) — extract header form + fe-admin/src/pages/pe/PurchaseEvaluationWorkspacePage.tsx (~120 LOC) ~ fe-admin/src/components/pe/PeDetailTabs.tsx — add mode prop + Section 5 hint ~ fe-admin/src/components/Layout.tsx — resolver Pe_*_Create map workspace ~ fe-admin/src/App.tsx — route /purchase-evaluations/workspace Verify: npm run build pass · dotnet test 83 vẫn pass (54 Domain + 29 Infra). fe-user mirror = Chunk 2. Co-Authored-By: Claude Opus 4.7 (1M context) --- fe-admin/src/App.tsx | 2 + fe-admin/src/components/Layout.tsx | 5 +- fe-admin/src/components/pe/PeDetailTabs.tsx | 19 +- fe-admin/src/components/pe/PeHeaderForm.tsx | 223 ++++++++++++++++++ fe-admin/src/components/pe/PeListPanel.tsx | 184 +++++++++++++++ .../pe/PurchaseEvaluationWorkspacePage.tsx | 140 +++++++++++ 6 files changed, 571 insertions(+), 2 deletions(-) create mode 100644 fe-admin/src/components/pe/PeHeaderForm.tsx create mode 100644 fe-admin/src/components/pe/PeListPanel.tsx create mode 100644 fe-admin/src/pages/pe/PurchaseEvaluationWorkspacePage.tsx diff --git a/fe-admin/src/App.tsx b/fe-admin/src/App.tsx index 9ac24cc..f44f924 100644 --- a/fe-admin/src/App.tsx +++ b/fe-admin/src/App.tsx @@ -21,6 +21,7 @@ import { ReportsPage } from '@/pages/ReportsPage' import { UsersPage } from '@/pages/system/UsersPage' import { PurchaseEvaluationsListPage, PurchaseEvaluationDetailPage } from '@/pages/pe/PurchaseEvaluationsListPage' import { PurchaseEvaluationCreatePage } from '@/pages/pe/PurchaseEvaluationCreatePage' +import { PurchaseEvaluationWorkspacePage } from '@/pages/pe/PurchaseEvaluationWorkspacePage' import { BudgetsListPage, BudgetDetailPage } from '@/pages/budgets/BudgetsListPage' import { BudgetCreatePage } from '@/pages/budgets/BudgetCreatePage' @@ -55,6 +56,7 @@ function App() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/fe-admin/src/components/Layout.tsx b/fe-admin/src/components/Layout.tsx index df576e3..ac5e553 100644 --- a/fe-admin/src/components/Layout.tsx +++ b/fe-admin/src/components/Layout.tsx @@ -78,7 +78,10 @@ function resolvePath(key: string): string | null { const typeInt = PE_CODE_TO_INT[code] if (!typeInt) return null if (action === 'List') return `/purchase-evaluations?type=${typeInt}` - if (action === 'Create') return `/purchase-evaluations/new?type=${typeInt}` + // "Thao tác" leaf → workspace 2-panel (Q4 2026-05-07): pick + create + sửa + // tables inline. Header-only `/new` page giữ tồn tại cho deep-link cũ + // (PeDetailTabs "Sửa header" button vẫn navigate sang đó). + if (action === 'Create') return `/purchase-evaluations/workspace?type=${typeInt}` if (action === 'Pending') return `/purchase-evaluations?type=${typeInt}&pendingMe=1` } // PE workflow admin leaf: PeWf_ → /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..." + /> +
+ +
+ +