[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:
pqhuy1987
2026-05-13 19:08:08 +07:00
parent a508564b45
commit d27caafcf5
6 changed files with 357 additions and 16 deletions

View File

@ -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,38 @@ 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á).
// Khi phase=ChoDuyet + workflow.AllowApproverEditDetails + actor match
// CurrentLevel.ApproverUserId → cho phép edit Section 2 dù readOnly=true.
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
// itemsReadOnly = readOnly trừ khi approver mode F3 mở
const itemsReadOnly = readOnly && !approverEditMode
// Mig 28 (S21 t4) — F2: Drafter skip thẳng Cấp cuối. Workflow phải bật flag.
// Default false (gửi tuần tự như cũ). Sync state với confirm dialog handler.
const [skipToFinal, setSkipToFinal] = useState(false)
const allowSkipToFinal = evaluation.workflowOptions?.allowDrafterSkipToFinal ?? false
// "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.
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,
// Mig 28 (S21 t4) — F2: Drafter skip thẳng Cấp cuối
skipToFinal: opts.skipToFinal,
})
},
onSuccess: () => {
@ -192,7 +214,15 @@ 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.
Banner cảnh báo "Bạn đang chỉnh sửa khi đang duyệt" khi approverEditMode. */}
{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 +281,34 @@ export function PeDetailTabs({
>
Lưu
</Button>
{/* Mig 28 (S21 t4) — F2: Drafter skip thẳng Cấp cuối checkbox.
Chỉ hiện khi workflow.AllowDrafterSkipToFinal=true. */}
{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>