diff --git a/fe-user/src/App.tsx b/fe-user/src/App.tsx index f2f5007..7539cb1 100644 --- a/fe-user/src/App.tsx +++ b/fe-user/src/App.tsx @@ -11,6 +11,7 @@ import { ContractDetailPage } from '@/pages/contracts/ContractDetailPage' import { MyContractsPage } from '@/pages/contracts/MyContractsPage' 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' @@ -33,6 +34,7 @@ function App() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/fe-user/src/components/Layout.tsx b/fe-user/src/components/Layout.tsx index 28ec8fe..7db7f1a 100644 --- a/fe-user/src/components/Layout.tsx +++ b/fe-user/src/components/Layout.tsx @@ -81,7 +81,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` } return null diff --git a/fe-user/src/components/pe/PeDetailTabs.tsx b/fe-user/src/components/pe/PeDetailTabs.tsx index ec81f72..1e81ed9 100644 --- a/fe-user/src/components/pe/PeDetailTabs.tsx +++ b/fe-user/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-user/src/components/pe/PeHeaderForm.tsx b/fe-user/src/components/pe/PeHeaderForm.tsx new file mode 100644 index 0000000..62312cd --- /dev/null +++ b/fe-user/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..." + /> +
+ +
+ +