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 <PeHeaderForm> | <PeDetailTabs
mode="workspace"> (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) <noreply@anthropic.com>
224 lines
7.4 KiB
TypeScript
224 lines
7.4 KiB
TypeScript
// 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<PeDetailBundle>(`/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<Paged<BudgetListItem>>('/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 (
|
|
<div className="max-w-2xl space-y-4 rounded-lg border border-slate-200 bg-white p-6 shadow-sm">
|
|
<header>
|
|
<h2 className="text-base font-semibold tracking-tight text-slate-900">
|
|
{editId ? 'Sửa header phiếu' : 'Tạo phiếu Duyệt NCC mới'}
|
|
</h2>
|
|
<p className="mt-0.5 text-[12px] text-slate-500">
|
|
{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.'}
|
|
</p>
|
|
</header>
|
|
|
|
<div>
|
|
<Label>Loại quy trình</Label>
|
|
<Select
|
|
value={form.type}
|
|
disabled={!!editId}
|
|
onChange={e => setForm({ ...form, type: Number(e.target.value) })}
|
|
>
|
|
{Object.values(PurchaseEvaluationType).map(t => (
|
|
<option key={t} value={t}>{PurchaseEvaluationTypeLabel[t]}</option>
|
|
))}
|
|
</Select>
|
|
</div>
|
|
|
|
<div>
|
|
<Label>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>
|
|
<Label>Dự án *</Label>
|
|
<Select
|
|
value={form.projectId}
|
|
disabled={!!editId}
|
|
onChange={e => setForm({ ...form, projectId: e.target.value })}
|
|
>
|
|
<option value="">-- Chọn --</option>
|
|
{projects.data?.map(p => (
|
|
<option key={p.id} value={p.id}>{p.code} — {p.name}</option>
|
|
))}
|
|
</Select>
|
|
</div>
|
|
|
|
<div>
|
|
<Label>Ngân sách (đối chiếu chi phí)</Label>
|
|
<Select
|
|
value={form.budgetId}
|
|
disabled={!form.projectId}
|
|
onChange={e => setForm({ ...form, budgetId: e.target.value })}
|
|
>
|
|
<option value="">— (không link)</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="mt-1 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.'
|
|
: 'Chỉ list ngân sách đã duyệt cùng dự án.'}
|
|
</p>
|
|
</div>
|
|
|
|
<div>
|
|
<Label>Địa điểm</Label>
|
|
<Input
|
|
value={form.diaDiem}
|
|
onChange={e => setForm({ ...form, diaDiem: e.target.value })}
|
|
placeholder="Lô K, KCN Lộc An - Bình Sơn..."
|
|
/>
|
|
</div>
|
|
|
|
<div>
|
|
<Label>Mô tả</Label>
|
|
<Textarea rows={3} value={form.moTa} onChange={e => setForm({ ...form, moTa: e.target.value })} />
|
|
</div>
|
|
|
|
<div>
|
|
<Label>Điều khoản thanh toán (JSON hoặc text)</Label>
|
|
<Textarea
|
|
rows={3}
|
|
value={form.paymentTerms}
|
|
onChange={e => setForm({ ...form, paymentTerms: e.target.value })}
|
|
placeholder='{"tamUng":"10%","thanhToanTam":"100% W.done","quyetToan":"Final Account","baoHanh":"5%"}'
|
|
/>
|
|
</div>
|
|
|
|
<div className="flex justify-end gap-2">
|
|
{onCancel && (
|
|
<Button variant="ghost" onClick={onCancel}>Hủy</Button>
|
|
)}
|
|
<Button
|
|
onClick={() => mut.mutate()}
|
|
disabled={!form.tenGoiThau || !form.projectId || mut.isPending}
|
|
>
|
|
{editId ? 'Lưu' : 'Tạo phiếu'}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|