[CLAUDE] FE-Admin+FE-User: Plan B Chunk D — ContractCreatePage Workspace V2 Select dropdown
Mirror PE PeWorkspaceCreateView Workspace pattern. Drafter pick V2 workflow IsUserSelectable=true filter ApplicableType=Contract(3). Changes × 2 app: - Add useQuery fetch /api/approval-workflows-v2?applicableType=3 + filter client-side isUserSelectable=true (mirror PE Mig 25 pattern) - Add Select dropdown "Quy trình duyệt V2 (tùy chọn)" trong ContractHeaderForm (create mode panel 2) - Wire approvalWorkflowId vào CreateContractCommand POST body - Conditional UI: blank = V1 fallback auto pick (7 prod contract behavior giữ nguyên); user pick V2 → pin ApprovalWorkflowId Mig 32 schema - Hint khi 0 workflows V2 admin ghim → message rõ V1 fallback Verify: - npm run build × 2 app PASS 0 TS err (1.32MB fe-user, 1.40MB fe-admin) - Mirror 2 app §3.9: +44 LOC mỗi file = +88 LOC total byte-similar - API endpoint /api/approval-workflows-v2 existing (Mig 25 Plan AA S24) - BE CreateContractCommand.ApprovalWorkflowId field đã add Chunk E1 (em main commit prior) — FE wire safe - Backward compat: V1 contract path unchanged khi user bỏ trống dropdown Plan B chain (6 chunks): - A158898e8✅ Entity ApprovalWorkflowId + CurrentApprovalLevelOrder - A2a85e437✅ Mig 32 + Seed sample V2 Contract workflow - B138469d✅ Service ApproveV2 branch (PE pattern mirror) - C26c98d3✅ Mig 33 ContractLevelOpinions - B21f199b0✅ UPSERT LevelOpinion block (PE Mig 26 mirror) - D (this) ✅ FE Workspace V2 dropdown - E FE Section 5 V2 (em main + Implementer split E1+E2 sau) Pattern 16-bis 4-place mirror check: - Page file × 2 app: edited (insertion mirror byte-similar) - App.tsx Routes: N/A (enhance existing /contracts/new route) - menuKeys.ts: N/A (không thêm menu key mới) - Layout staticMap: N/A (route unchanged) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@ -309,6 +309,11 @@ function ContractHeaderForm({
|
|||||||
const [budgetManual, setBudgetManual] = useState(false)
|
const [budgetManual, setBudgetManual] = useState(false)
|
||||||
const [budgetManualName, setBudgetManualName] = useState('')
|
const [budgetManualName, setBudgetManualName] = useState('')
|
||||||
const [budgetManualAmount, setBudgetManualAmount] = useState(0)
|
const [budgetManualAmount, setBudgetManualAmount] = useState(0)
|
||||||
|
// [Plan B S29 Chunk D 2026-05-22 Mig 32] V2 workflow pin lúc create — mirror
|
||||||
|
// PE PeWorkspaceCreateView pattern. Drafter pick V2 workflow IsUserSelectable
|
||||||
|
// filter ApplicableType=Contract(3). Nếu blank → BE fallback V1 auto pick
|
||||||
|
// (7 prod contract giữ behavior cũ).
|
||||||
|
const [approvalWorkflowId, setApprovalWorkflowId] = useState('')
|
||||||
|
|
||||||
// Reset type về default khi typeFilter (parent prop) thay đổi
|
// Reset type về default khi typeFilter (parent prop) thay đổi
|
||||||
useEffect(() => { setType(defaultType) }, [defaultType])
|
useEffect(() => { setType(defaultType) }, [defaultType])
|
||||||
@ -327,6 +332,19 @@ function ContractHeaderForm({
|
|||||||
queryKey: ['templates-by-type', type],
|
queryKey: ['templates-by-type', type],
|
||||||
queryFn: async () => (await api.get<ContractTemplate[]>('/forms/templates', { params: { type } })).data,
|
queryFn: async () => (await api.get<ContractTemplate[]>('/forms/templates', { params: { type } })).data,
|
||||||
})
|
})
|
||||||
|
// [Plan B S29 Chunk D Mig 32] V2 workflows ApplicableType=Contract(3) filter
|
||||||
|
// IsUserSelectable=true (admin ghim cho user pick). Mirror PE Mig 25 pattern.
|
||||||
|
const approvalWorkflows = useQuery({
|
||||||
|
queryKey: ['approval-workflows-v2-contract'],
|
||||||
|
queryFn: async () => {
|
||||||
|
const res = await api.get<{ types: { applicableType: number; history: { id: string; code: string; version: number; name: string; isActive: boolean; isUserSelectable: boolean }[] }[] }>(
|
||||||
|
'/approval-workflows-v2',
|
||||||
|
{ params: { applicableType: 3 } },
|
||||||
|
)
|
||||||
|
const typeBucket = res.data.types.find(t => t.applicableType === 3)
|
||||||
|
return (typeBucket?.history ?? []).filter(w => w.isUserSelectable)
|
||||||
|
},
|
||||||
|
})
|
||||||
// Eligible Budgets: cùng Project + Phase=DaDuyet (BE-side filter).
|
// Eligible Budgets: cùng Project + Phase=DaDuyet (BE-side filter).
|
||||||
const eligibleBudgets = useQuery({
|
const eligibleBudgets = useQuery({
|
||||||
queryKey: ['eligible-budgets', projectId],
|
queryKey: ['eligible-budgets', projectId],
|
||||||
@ -357,6 +375,9 @@ function ContractHeaderForm({
|
|||||||
bypassProcurementAndCCM: bypass,
|
bypassProcurementAndCCM: bypass,
|
||||||
draftData: null,
|
draftData: null,
|
||||||
...budgetPayload,
|
...budgetPayload,
|
||||||
|
// [Plan B S29 Chunk D Mig 32] Pin V2 workflow nếu user chọn; null →
|
||||||
|
// BE fallback V1 auto pick.
|
||||||
|
approvalWorkflowId: approvalWorkflowId || null,
|
||||||
})
|
})
|
||||||
return res.data.id
|
return res.data.id
|
||||||
},
|
},
|
||||||
@ -395,6 +416,29 @@ function ContractHeaderForm({
|
|||||||
templates={templates.data ?? []}
|
templates={templates.data ?? []}
|
||||||
typeReadonly={false}
|
typeReadonly={false}
|
||||||
/>
|
/>
|
||||||
|
{/* [Plan B S29 Chunk D Mig 32] V2 workflow picker — mutually exclusive
|
||||||
|
với V1. Blank → BE fallback V1 auto pick (7 prod contract behavior).
|
||||||
|
Mirror PE PeWorkspaceCreateView pattern. */}
|
||||||
|
<div className="mt-4 space-y-1.5">
|
||||||
|
<Label>Quy trình duyệt V2 <span className="text-[10px] font-normal text-slate-400">(tùy chọn — bỏ trống = dùng V1 mặc định)</span></Label>
|
||||||
|
<Select
|
||||||
|
value={approvalWorkflowId}
|
||||||
|
onChange={e => setApprovalWorkflowId(e.target.value)}
|
||||||
|
>
|
||||||
|
<option value="">— V1 mặc định (auto pick) —</option>
|
||||||
|
{approvalWorkflows.data?.map(w => (
|
||||||
|
<option key={w.id} value={w.id}>
|
||||||
|
{w.code} v{String(w.version).padStart(2, '0')} — {w.name}
|
||||||
|
{w.isActive ? ' (đang áp dụng)' : ''}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
{approvalWorkflows.data && approvalWorkflows.data.length === 0 && (
|
||||||
|
<p className="text-[11px] text-slate-500">
|
||||||
|
Chưa có quy trình duyệt V2 nào được admin ghim cho HĐ — HĐ sẽ chạy theo V1 mặc định.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
<div className="mt-4 space-y-1.5">
|
<div className="mt-4 space-y-1.5">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<Label className="mb-0">Ngân sách (đối chiếu chi phí)</Label>
|
<Label className="mb-0">Ngân sách (đối chiếu chi phí)</Label>
|
||||||
|
|||||||
@ -309,6 +309,11 @@ function ContractHeaderForm({
|
|||||||
const [budgetManual, setBudgetManual] = useState(false)
|
const [budgetManual, setBudgetManual] = useState(false)
|
||||||
const [budgetManualName, setBudgetManualName] = useState('')
|
const [budgetManualName, setBudgetManualName] = useState('')
|
||||||
const [budgetManualAmount, setBudgetManualAmount] = useState(0)
|
const [budgetManualAmount, setBudgetManualAmount] = useState(0)
|
||||||
|
// [Plan B S29 Chunk D 2026-05-22 Mig 32] V2 workflow pin lúc create — mirror
|
||||||
|
// PE PeWorkspaceCreateView pattern. Drafter pick V2 workflow IsUserSelectable
|
||||||
|
// filter ApplicableType=Contract(3). Nếu blank → BE fallback V1 auto pick
|
||||||
|
// (7 prod contract giữ behavior cũ).
|
||||||
|
const [approvalWorkflowId, setApprovalWorkflowId] = useState('')
|
||||||
|
|
||||||
// Reset type về default khi typeFilter (parent prop) thay đổi
|
// Reset type về default khi typeFilter (parent prop) thay đổi
|
||||||
useEffect(() => { setType(defaultType) }, [defaultType])
|
useEffect(() => { setType(defaultType) }, [defaultType])
|
||||||
@ -327,6 +332,19 @@ function ContractHeaderForm({
|
|||||||
queryKey: ['templates-by-type', type],
|
queryKey: ['templates-by-type', type],
|
||||||
queryFn: async () => (await api.get<ContractTemplate[]>('/forms/templates', { params: { type } })).data,
|
queryFn: async () => (await api.get<ContractTemplate[]>('/forms/templates', { params: { type } })).data,
|
||||||
})
|
})
|
||||||
|
// [Plan B S29 Chunk D Mig 32] V2 workflows ApplicableType=Contract(3) filter
|
||||||
|
// IsUserSelectable=true (admin ghim cho user pick). Mirror PE Mig 25 pattern.
|
||||||
|
const approvalWorkflows = useQuery({
|
||||||
|
queryKey: ['approval-workflows-v2-contract'],
|
||||||
|
queryFn: async () => {
|
||||||
|
const res = await api.get<{ types: { applicableType: number; history: { id: string; code: string; version: number; name: string; isActive: boolean; isUserSelectable: boolean }[] }[] }>(
|
||||||
|
'/approval-workflows-v2',
|
||||||
|
{ params: { applicableType: 3 } },
|
||||||
|
)
|
||||||
|
const typeBucket = res.data.types.find(t => t.applicableType === 3)
|
||||||
|
return (typeBucket?.history ?? []).filter(w => w.isUserSelectable)
|
||||||
|
},
|
||||||
|
})
|
||||||
// Eligible Budgets: cùng Project + Phase=DaDuyet (BE-side filter).
|
// Eligible Budgets: cùng Project + Phase=DaDuyet (BE-side filter).
|
||||||
const eligibleBudgets = useQuery({
|
const eligibleBudgets = useQuery({
|
||||||
queryKey: ['eligible-budgets', projectId],
|
queryKey: ['eligible-budgets', projectId],
|
||||||
@ -357,6 +375,9 @@ function ContractHeaderForm({
|
|||||||
bypassProcurementAndCCM: bypass,
|
bypassProcurementAndCCM: bypass,
|
||||||
draftData: null,
|
draftData: null,
|
||||||
...budgetPayload,
|
...budgetPayload,
|
||||||
|
// [Plan B S29 Chunk D Mig 32] Pin V2 workflow nếu user chọn; null →
|
||||||
|
// BE fallback V1 auto pick.
|
||||||
|
approvalWorkflowId: approvalWorkflowId || null,
|
||||||
})
|
})
|
||||||
return res.data.id
|
return res.data.id
|
||||||
},
|
},
|
||||||
@ -395,6 +416,29 @@ function ContractHeaderForm({
|
|||||||
templates={templates.data ?? []}
|
templates={templates.data ?? []}
|
||||||
typeReadonly={false}
|
typeReadonly={false}
|
||||||
/>
|
/>
|
||||||
|
{/* [Plan B S29 Chunk D Mig 32] V2 workflow picker — mutually exclusive
|
||||||
|
với V1. Blank → BE fallback V1 auto pick (7 prod contract behavior).
|
||||||
|
Mirror PE PeWorkspaceCreateView pattern. */}
|
||||||
|
<div className="mt-4 space-y-1.5">
|
||||||
|
<Label>Quy trình duyệt V2 <span className="text-[10px] font-normal text-slate-400">(tùy chọn — bỏ trống = dùng V1 mặc định)</span></Label>
|
||||||
|
<Select
|
||||||
|
value={approvalWorkflowId}
|
||||||
|
onChange={e => setApprovalWorkflowId(e.target.value)}
|
||||||
|
>
|
||||||
|
<option value="">— V1 mặc định (auto pick) —</option>
|
||||||
|
{approvalWorkflows.data?.map(w => (
|
||||||
|
<option key={w.id} value={w.id}>
|
||||||
|
{w.code} v{String(w.version).padStart(2, '0')} — {w.name}
|
||||||
|
{w.isActive ? ' (đang áp dụng)' : ''}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
{approvalWorkflows.data && approvalWorkflows.data.length === 0 && (
|
||||||
|
<p className="text-[11px] text-slate-500">
|
||||||
|
Chưa có quy trình duyệt V2 nào được admin ghim cho HĐ — HĐ sẽ chạy theo V1 mặc định.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
<div className="mt-4 space-y-1.5">
|
<div className="mt-4 space-y-1.5">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<Label className="mb-0">Ngân sách (đối chiếu chi phí)</Label>
|
<Label className="mb-0">Ngân sách (đối chiếu chi phí)</Label>
|
||||||
|
|||||||
Reference in New Issue
Block a user