[CLAUDE] FE-PE: Chunk D — eOffice Trả lại modes + Skip CEO + Approver edit Section 2 (F1+F2+F3) mirror 2 app
Types (fe-{admin,user}/src/types/purchaseEvaluation.ts):
- ApprovalWorkflowOptions type (6 boolean Allow* flag)
- WorkflowReturnMode const-object {OneLevel,OneStep,Assignee,Drafter}
- PeDetailBundle +workflowOptions field (null nếu V1 legacy)
PeWorkflowPanel.tsx F1 (mirror 2 app):
- State returnMode + returnTargetUserId thêm vào transition mutation payload
- Dialog Trả lại render radio list 1-4 mode enabled theo workflowOptions:
• Trả về 1 Cấp trước (lùi pointer trong cùng Bước, peer review)
• Trả về 1 Bước trước (Cấp cuối Bước trước nhận lại)
• Trả về Người chỉ định (pick từ dropdown NV đã ký levelOpinions)
• Trả về Người soạn thảo (default Drafter S17 fallback)
- Banner amber rounded box dưới radio list mô tả hành vi mode chọn
- onSuccess reset returnMode về Drafter + returnTargetUserId null
PeDetailTabs.tsx F2 (mirror 2 app):
- State skipToFinal + allowSkipToFinal (từ workflowOptions)
- submitForApproval mutationFn accept opts.skipToFinal → POST body
- Workspace action bar: thêm checkbox violet "Gửi thẳng Cấp cuối (skip trung gian)"
hiển thị conditional theo allowSkipToFinal + canSubmitForApproval
- Confirm dialog message dynamic: "Gửi thẳng" warning vs default tuần tự
- Button label dynamic: "Lưu & Gửi thẳng CẤP CUỐI →" vs "Lưu & Gửi Duyệt →"
PeDetailTabs.tsx F3 (mirror 2 app):
- useAuth import + compute approverEditMode (phase=ChoDuyet +
workflow.AllowApproverEditDetails + actor match currentApproval.approvers)
- itemsReadOnly = readOnly && !approverEditMode → ItemsTab nhận
- Banner violet "ⓘ Bạn được phép chỉnh sửa Hạng mục/NCC/Báo giá" khi
approverEditMode + readOnly (Duyệt menu) — UX nhắc về quyền extended
InfoTab + NccSelectorRow + BudgetFieldRow GIỮ strict isEditablePhase (KHÔNG
trong F3 scope — Header section + Section 3 winner KHÔNG cho Approver edit).
Verify:
- npm run build × 2 app pass (fe-user 7.52s, fe-admin 499ms cached)
- 0 TS6 err, warning chunk size pre-existing
- BE Chunk B đã accept skipToFinal + returnMode + returnTargetUserId trong
TransitionPurchaseEvaluationCommand → wire E2E complete
Pending Chunk E: Docs schema-diagram §14 update + STATUS + HANDOFF + session log.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@ -15,6 +15,7 @@ import { Select } from '@/components/ui/Select'
|
||||
import { api } from '@/lib/api'
|
||||
import { getErrorMessage } from '@/lib/apiError'
|
||||
import { cn } from '@/lib/cn'
|
||||
import { useAuth } from '@/contexts/AuthContext'
|
||||
import {
|
||||
PeAttachmentPurpose,
|
||||
PeAttachmentPurposeLabel,
|
||||
@ -100,17 +101,33 @@ export function PeDetailTabs({
|
||||
const canEditPhase = isEditablePhase(evaluation.phase)
|
||||
const opinionsReadOnly = readOnly || mode === 'workspace'
|
||||
|
||||
// Mig 28 (S21 t4) — F3: Approver edit Section 2 (Hạng mục + NCC + Báo giá).
|
||||
const { user: currentUser } = useAuth()
|
||||
const isAdmin = currentUser?.roles?.includes('Admin') ?? false
|
||||
const v2Approvers = evaluation.currentApproval?.approvers ?? []
|
||||
const actorMatchesLevel = isAdmin
|
||||
|| (currentUser?.id != null && v2Approvers.some(a => a.userId === currentUser.id))
|
||||
const approverEditMode = evaluation.phase === PurchaseEvaluationPhase.ChoDuyet
|
||||
&& (evaluation.workflowOptions?.allowApproverEditDetails ?? false)
|
||||
&& actorMatchesLevel
|
||||
const itemsReadOnly = readOnly && !approverEditMode
|
||||
|
||||
// "Lưu & Gửi Duyệt" workspace mode (user 2026-05-07): trigger transition
|
||||
// sang phase tiếp theo (= Đã gửi duyệt). nextPhases[0] thường là ChoPurchasing
|
||||
// (skip TuChoi). Sau success → toast + invalidate + onBack đóng workspace.
|
||||
// Mig 28 (S21 t4) — F2: Drafter skip thẳng Cấp cuối. Workflow phải bật flag.
|
||||
const [skipToFinal, setSkipToFinal] = useState(false)
|
||||
const allowSkipToFinal = evaluation.workflowOptions?.allowDrafterSkipToFinal ?? false
|
||||
|
||||
const submitForApproval = useMutation({
|
||||
mutationFn: async () => {
|
||||
mutationFn: async (opts: { skipToFinal: boolean }) => {
|
||||
const next = evaluation.workflow.nextPhases.find(p => p !== PurchaseEvaluationPhase.TuChoi && p !== PurchaseEvaluationPhase.TraLai)
|
||||
if (!next) throw new Error('Không có phase tiếp theo để gửi duyệt')
|
||||
return api.post(`/purchase-evaluations/${evaluation.id}/transitions`, {
|
||||
targetPhase: next,
|
||||
decision: 1,
|
||||
comment: null,
|
||||
skipToFinal: opts.skipToFinal,
|
||||
})
|
||||
},
|
||||
onSuccess: () => {
|
||||
@ -192,7 +209,14 @@ export function PeDetailTabs({
|
||||
<InfoTab ev={evaluation} readOnly={readOnly} autoEdit={autoEditHeader} />
|
||||
</Section>
|
||||
<Section title={`2. Hạng mục + Báo giá NCC (${evaluation.details.length} hạng mục · ${evaluation.suppliers.length} NCC)`}>
|
||||
<ItemsTab ev={evaluation} readOnly={readOnly} />
|
||||
{/* Mig 28 (S21 t4) — F3: itemsReadOnly cho phép approver edit Section 2 */}
|
||||
{approverEditMode && readOnly && (
|
||||
<div className="mx-5 mt-2 rounded border border-violet-200 bg-violet-50 px-3 py-2 text-[11px] text-violet-800">
|
||||
ⓘ Bạn được phép chỉnh sửa Hạng mục / NCC / Báo giá (workflow bật mode Approver edit).
|
||||
Mọi thay đổi sẽ được ghi vào Lịch sử chỉnh sửa.
|
||||
</div>
|
||||
)}
|
||||
<ItemsTab ev={evaluation} readOnly={itemsReadOnly} />
|
||||
</Section>
|
||||
<Section title="3. Chọn NCC / TP thắng thầu">
|
||||
<ChonNccSection ev={evaluation} readOnly={readOnly} />
|
||||
@ -251,18 +275,33 @@ export function PeDetailTabs({
|
||||
>
|
||||
Lưu
|
||||
</Button>
|
||||
{/* Mig 28 (S21 t4) — F2: Drafter skip checkbox */}
|
||||
{allowSkipToFinal && canSubmitForApproval && (
|
||||
<label className="flex cursor-pointer items-center gap-1.5 rounded border border-violet-200 bg-violet-50 px-2 py-1 text-[11px] text-violet-800">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="h-3 w-3"
|
||||
checked={skipToFinal}
|
||||
onChange={e => setSkipToFinal(e.target.checked)}
|
||||
/>
|
||||
<span>Gửi thẳng Cấp cuối (skip trung gian)</span>
|
||||
</label>
|
||||
)}
|
||||
<Button
|
||||
onClick={() => {
|
||||
if (!forwardPhase) return
|
||||
if (confirm(`Gửi phiếu vào quy trình duyệt? Sẽ chuyển sang "${PurchaseEvaluationPhaseLabel[forwardPhase]}". Sau khi gửi sẽ KHÔNG sửa được nữa (trừ khi approver Trả lại).`)) {
|
||||
submitForApproval.mutate()
|
||||
const confirmMsg = skipToFinal
|
||||
? `Gửi THẲNG CẤP CUỐI bỏ qua các Cấp trung gian? Hệ thống sẽ ghi audit "Drafter skip" — không quay lại được trừ khi approver Trả lại.`
|
||||
: `Gửi phiếu vào quy trình duyệt? Sẽ chuyển sang "${PurchaseEvaluationPhaseLabel[forwardPhase]}". Sau khi gửi sẽ KHÔNG sửa được nữa (trừ khi approver Trả lại).`
|
||||
if (confirm(confirmMsg)) {
|
||||
submitForApproval.mutate({ skipToFinal })
|
||||
}
|
||||
}}
|
||||
disabled={!canSubmitForApproval || submitForApproval.isPending}
|
||||
title={submitDisabledReason ?? `Gửi phiếu sang "${forwardPhase ? PurchaseEvaluationPhaseLabel[forwardPhase] : '?'}"`}
|
||||
className="text-xs"
|
||||
>
|
||||
{submitForApproval.isPending ? 'Đang gửi…' : 'Lưu & Gửi Duyệt →'}
|
||||
{submitForApproval.isPending ? 'Đang gửi…' : skipToFinal ? 'Lưu & Gửi thẳng CẤP CUỐI →' : 'Lưu & Gửi Duyệt →'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -18,6 +18,7 @@ import {
|
||||
PurchaseEvaluationPhase,
|
||||
PurchaseEvaluationPhaseColor,
|
||||
PurchaseEvaluationPhaseLabel,
|
||||
WorkflowReturnMode,
|
||||
type PeDepartmentApproval,
|
||||
type PeDetailBundle,
|
||||
} from '@/types/purchaseEvaluation'
|
||||
@ -33,10 +34,20 @@ export function PeWorkflowPanel({
|
||||
}) {
|
||||
const [target, setTarget] = useState<number | null>(null)
|
||||
const [comment, setComment] = useState('')
|
||||
// Mig 28 (S21 t4) — F1 mode Trả lại. Default Drafter (backward compat S17).
|
||||
const [returnMode, setReturnMode] = useState<WorkflowReturnMode>(WorkflowReturnMode.Drafter)
|
||||
const [returnTargetUserId, setReturnTargetUserId] = useState<string | null>(null)
|
||||
const qc = useQueryClient()
|
||||
const { user: currentUser } = useAuth()
|
||||
const isAdmin = currentUser?.roles?.includes('Admin') ?? false
|
||||
|
||||
// Mig 28 — F1 workflow options. Null nếu V1 legacy → fallback chỉ "Trả về Drafter".
|
||||
const wfOptions = evaluation.workflowOptions
|
||||
// List approvers đã ký (cho mode Assignee dropdown pick)
|
||||
const signedApprovers = (evaluation.levelOpinions ?? [])
|
||||
.map(o => ({ userId: o.approverUserId, fullName: o.approverFullName ?? 'NV' }))
|
||||
.filter((v, i, arr) => arr.findIndex(x => x.userId === v.userId) === i)
|
||||
|
||||
// Mig 24 — V2 schema chỉ cho phép approver trong CurrentApproval.approvers
|
||||
// duyệt cấp hiện tại. Nếu actor không khớp → disable nút "Duyệt forward"
|
||||
// (Trả lại / Từ chối vẫn enabled vì Service không kiểm Bước/Cấp với 2
|
||||
@ -75,10 +86,16 @@ export function PeWorkflowPanel({
|
||||
&& evaluation.phase !== PurchaseEvaluationPhase.DangSoanThao)
|
||||
|| (target === PurchaseEvaluationPhase.TraLai
|
||||
&& evaluation.phase !== PurchaseEvaluationPhase.TraLai)
|
||||
// Mig 28 (S21 t4) — F1: chỉ gửi returnMode khi target=TraLai + mode != null
|
||||
const isTraLaiAction = target === PurchaseEvaluationPhase.TraLai
|
||||
&& evaluation.phase !== PurchaseEvaluationPhase.TraLai
|
||||
return api.post(`/purchase-evaluations/${evaluation.id}/transitions`, {
|
||||
targetPhase: target,
|
||||
decision: isReject ? 2 : 1,
|
||||
comment: comment || null,
|
||||
returnMode: isTraLaiAction ? returnMode : null,
|
||||
returnTargetUserId: isTraLaiAction && returnMode === WorkflowReturnMode.Assignee
|
||||
? returnTargetUserId : null,
|
||||
})
|
||||
},
|
||||
onSuccess: () => {
|
||||
@ -88,6 +105,8 @@ export function PeWorkflowPanel({
|
||||
qc.invalidateQueries({ queryKey: ['pe-dept-approvals', evaluation.id] })
|
||||
setTarget(null)
|
||||
setComment('')
|
||||
setReturnMode(WorkflowReturnMode.Drafter)
|
||||
setReturnTargetUserId(null)
|
||||
},
|
||||
onError: e => toast.error(getErrorMessage(e)),
|
||||
})
|
||||
@ -285,9 +304,96 @@ export function PeWorkflowPanel({
|
||||
</div>
|
||||
)}
|
||||
{isSendBack && (
|
||||
<div className="mb-3 rounded border border-amber-200 bg-amber-50 px-3 py-2 text-[11px] text-amber-800">
|
||||
Phiếu sẽ về “Đang soạn thảo”. Drafter có thể sửa rồi trình lại — workflow tự jump tới phase này.
|
||||
</div>
|
||||
<>
|
||||
{/* Mig 28 (S21 t4) — F1 mode picker khi Trả lại. Show modes
|
||||
enabled per workflow.options. Default Drafter (S17 fallback). */}
|
||||
{(wfOptions?.allowReturnOneLevel
|
||||
|| wfOptions?.allowReturnOneStep
|
||||
|| wfOptions?.allowReturnToAssignee
|
||||
|| wfOptions?.allowReturnToDrafter
|
||||
|| !wfOptions) && (
|
||||
<div className="mb-3 space-y-1.5">
|
||||
<Label className="text-[12px]">Chọn cách Trả lại</Label>
|
||||
<div className="space-y-1">
|
||||
{(wfOptions?.allowReturnOneLevel) && (
|
||||
<label className="flex items-start gap-2 rounded border border-amber-200 bg-white px-2 py-1.5 text-[12px] hover:bg-amber-50/40">
|
||||
<input
|
||||
type="radio"
|
||||
className="mt-0.5"
|
||||
checked={returnMode === WorkflowReturnMode.OneLevel}
|
||||
onChange={() => setReturnMode(WorkflowReturnMode.OneLevel)}
|
||||
/>
|
||||
<span>
|
||||
<span className="font-medium">Trả về 1 Cấp trước</span>
|
||||
<span className="block text-[10px] text-slate-500">Lùi 1 Cấp trong cùng Bước. NV cấp trước nhận lại.</span>
|
||||
</span>
|
||||
</label>
|
||||
)}
|
||||
{(wfOptions?.allowReturnOneStep) && (
|
||||
<label className="flex items-start gap-2 rounded border border-amber-200 bg-white px-2 py-1.5 text-[12px] hover:bg-amber-50/40">
|
||||
<input
|
||||
type="radio"
|
||||
className="mt-0.5"
|
||||
checked={returnMode === WorkflowReturnMode.OneStep}
|
||||
onChange={() => setReturnMode(WorkflowReturnMode.OneStep)}
|
||||
/>
|
||||
<span>
|
||||
<span className="font-medium">Trả về 1 Bước trước</span>
|
||||
<span className="block text-[10px] text-slate-500">Lùi sang Bước trước, NV Cấp cuối Bước đó nhận lại.</span>
|
||||
</span>
|
||||
</label>
|
||||
)}
|
||||
{(wfOptions?.allowReturnToAssignee) && (
|
||||
<label className="flex items-start gap-2 rounded border border-amber-200 bg-white px-2 py-1.5 text-[12px] hover:bg-amber-50/40">
|
||||
<input
|
||||
type="radio"
|
||||
className="mt-0.5"
|
||||
checked={returnMode === WorkflowReturnMode.Assignee}
|
||||
onChange={() => setReturnMode(WorkflowReturnMode.Assignee)}
|
||||
/>
|
||||
<span className="flex-1">
|
||||
<span className="font-medium">Trả về Người chỉ định</span>
|
||||
<span className="block text-[10px] text-slate-500">Pick từ list NV đã duyệt trước đó. Workflow set Cấp/Bước của NV.</span>
|
||||
{returnMode === WorkflowReturnMode.Assignee && (
|
||||
<select
|
||||
className="mt-1.5 w-full rounded border border-slate-300 px-2 py-1 text-[12px]"
|
||||
value={returnTargetUserId ?? ''}
|
||||
onChange={e => setReturnTargetUserId(e.target.value || null)}
|
||||
>
|
||||
<option value="">— Chọn NV —</option>
|
||||
{signedApprovers.map(a => (
|
||||
<option key={a.userId} value={a.userId}>{a.fullName}</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
</span>
|
||||
</label>
|
||||
)}
|
||||
{(wfOptions?.allowReturnToDrafter !== false) && (
|
||||
<label className="flex items-start gap-2 rounded border border-amber-200 bg-white px-2 py-1.5 text-[12px] hover:bg-amber-50/40">
|
||||
<input
|
||||
type="radio"
|
||||
className="mt-0.5"
|
||||
checked={returnMode === WorkflowReturnMode.Drafter}
|
||||
onChange={() => setReturnMode(WorkflowReturnMode.Drafter)}
|
||||
/>
|
||||
<span>
|
||||
<span className="font-medium">Trả về Người soạn thảo (mặc định)</span>
|
||||
<span className="block text-[10px] text-slate-500">Phase → "Trả lại". Drafter sửa rồi gửi lại chạy từ Cấp 1 Bước 1.</span>
|
||||
</span>
|
||||
</label>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="mb-3 rounded border border-amber-200 bg-amber-50 px-3 py-2 text-[11px] text-amber-800">
|
||||
{returnMode === WorkflowReturnMode.Drafter
|
||||
? 'Phiếu sẽ về "Trả lại". Drafter có thể sửa rồi trình lại từ Cấp 1 Bước 1.'
|
||||
: returnMode === WorkflowReturnMode.Assignee
|
||||
? 'Phiếu sẽ về Cấp/Bước của NV đã chọn (vẫn "Đã gửi duyệt"). NV nhận lại để duyệt tiếp.'
|
||||
: 'Phiếu sẽ lùi pointer (vẫn "Đã gửi duyệt"). NV trước nhận lại để duyệt tiếp.'}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<Label>Ghi chú (tùy chọn)</Label>
|
||||
<Textarea value={comment} onChange={e => setComment(e.target.value)} rows={3} />
|
||||
|
||||
@ -347,6 +347,25 @@ export type PeDepartmentApproval = {
|
||||
isBypassed: boolean
|
||||
}
|
||||
|
||||
// Mig 28 (S21 t4) — 6 advanced options của workflow pin.
|
||||
export type ApprovalWorkflowOptions = {
|
||||
allowReturnOneLevel: boolean
|
||||
allowReturnOneStep: boolean
|
||||
allowReturnToAssignee: boolean
|
||||
allowReturnToDrafter: boolean
|
||||
allowDrafterSkipToFinal: boolean
|
||||
allowApproverEditDetails: boolean
|
||||
}
|
||||
|
||||
// Mig 28 (S21 t4) — F1 mode Trả lại payload gửi BE
|
||||
export const WorkflowReturnMode = {
|
||||
OneLevel: 1,
|
||||
OneStep: 2,
|
||||
Assignee: 3,
|
||||
Drafter: 4,
|
||||
} as const
|
||||
export type WorkflowReturnMode = typeof WorkflowReturnMode[keyof typeof WorkflowReturnMode]
|
||||
|
||||
export type PeDetailBundle = {
|
||||
id: string
|
||||
maPhieu: string | null
|
||||
@ -378,6 +397,8 @@ export type PeDetailBundle = {
|
||||
approvalWorkflowCode: string | null
|
||||
approvalWorkflowName: string | null
|
||||
approvalWorkflowVersion: number | null
|
||||
// Mig 28 (S21 t4) — 6 advanced options của workflow pin. Null nếu V1 legacy.
|
||||
workflowOptions: ApprovalWorkflowOptions | null
|
||||
// Mig 24 — info Bước/Cấp đang chờ duyệt (chỉ populate khi pin V2 + Phase=ChoDuyet)
|
||||
currentApproval: PeCurrentApproval | null
|
||||
// Mig 24 — full flow snapshot (Bước → Cấp → NV) với Status per level
|
||||
|
||||
Reference in New Issue
Block a user