[CLAUDE] FE-Admin+FE-User: Chunk E2 — 2-stage dept approval timeline panel (PE)
FE Workflow Panel hiển thị progress 2-cấp duyệt phòng ban (Migration 16):
- Section "Tiến trình duyệt 2-cấp phòng ban" trong PeWorkflowPanel
- Group rows by Phase × Department, show Stage Review NV + Confirm TPB
- Highlight amber "chờ TPB confirm" khi current phase có Review nhưng chưa Confirm
- Badge fuchsia "bypass" khi NV được CanBypassReview
- useQuery fetch endpoint GET /pe/{id}/department-approvals
- Invalidate query sau transition để refresh ngay
Type mới: ApprovalStage const + PeDepartmentApproval DTO trong types/purchaseEvaluation.ts.
User flow anh Kiệt test:
- phuong.nguyen (NV.PRO) Duyệt phase ChoPurchasing
→ row Review xuất hiện, panel hiển thị "⏳ chờ TPB confirm" (amber)
- tra.bui (TPB.PRO, DeptManager) Duyệt
→ row Confirm xuất hiện (emerald) + phase chuyển sang ChoCCM
2 file đồng bộ giữa fe-admin + fe-user (rule §3.9 duplicate có chủ đích).
Build: cả 2 FE pass.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@ -3,7 +3,7 @@
|
|||||||
// action button. Approvals + History moved here from PeDetailTabs (2 section
|
// action button. Approvals + History moved here from PeDetailTabs (2 section
|
||||||
// dưới cùng) để Panel 2 tập trung hiển thị nội dung phiếu (Info + NCC + Items).
|
// dưới cùng) để Panel 2 tập trung hiển thị nội dung phiếu (Info + NCC + Items).
|
||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
import { Dialog } from '@/components/ui/Dialog'
|
import { Dialog } from '@/components/ui/Dialog'
|
||||||
import { Button } from '@/components/ui/Button'
|
import { Button } from '@/components/ui/Button'
|
||||||
@ -13,9 +13,11 @@ import { api } from '@/lib/api'
|
|||||||
import { getErrorMessage } from '@/lib/apiError'
|
import { getErrorMessage } from '@/lib/apiError'
|
||||||
import { cn } from '@/lib/cn'
|
import { cn } from '@/lib/cn'
|
||||||
import {
|
import {
|
||||||
|
ApprovalStage,
|
||||||
PurchaseEvaluationPhase,
|
PurchaseEvaluationPhase,
|
||||||
PurchaseEvaluationPhaseColor,
|
PurchaseEvaluationPhaseColor,
|
||||||
PurchaseEvaluationPhaseLabel,
|
PurchaseEvaluationPhaseLabel,
|
||||||
|
type PeDepartmentApproval,
|
||||||
type PeDetailBundle,
|
type PeDetailBundle,
|
||||||
} from '@/types/purchaseEvaluation'
|
} from '@/types/purchaseEvaluation'
|
||||||
import { PeApprovalsSection, PeHistorySection } from './PeDetailTabs'
|
import { PeApprovalsSection, PeHistorySection } from './PeDetailTabs'
|
||||||
@ -25,6 +27,16 @@ export function PeWorkflowPanel({ evaluation }: { evaluation: PeDetailBundle })
|
|||||||
const [comment, setComment] = useState('')
|
const [comment, setComment] = useState('')
|
||||||
const qc = useQueryClient()
|
const qc = useQueryClient()
|
||||||
|
|
||||||
|
// 2-stage dept approvals (Migration 16) — fetch riêng để FE Workflow Panel
|
||||||
|
// hiển thị progress per phase × dept (Stage Review NV / Confirm TPB).
|
||||||
|
const { data: deptApprovals = [] } = useQuery<PeDepartmentApproval[]>({
|
||||||
|
queryKey: ['pe-dept-approvals', evaluation.id],
|
||||||
|
queryFn: async () => {
|
||||||
|
const r = await api.get(`/purchase-evaluations/${evaluation.id}/department-approvals`)
|
||||||
|
return r.data
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
const transition = useMutation({
|
const transition = useMutation({
|
||||||
mutationFn: async () =>
|
mutationFn: async () =>
|
||||||
api.post(`/purchase-evaluations/${evaluation.id}/transitions`, {
|
api.post(`/purchase-evaluations/${evaluation.id}/transitions`, {
|
||||||
@ -36,6 +48,7 @@ export function PeWorkflowPanel({ evaluation }: { evaluation: PeDetailBundle })
|
|||||||
toast.success('Đã chuyển phase.')
|
toast.success('Đã chuyển phase.')
|
||||||
qc.invalidateQueries({ queryKey: ['pe-detail', evaluation.id] })
|
qc.invalidateQueries({ queryKey: ['pe-detail', evaluation.id] })
|
||||||
qc.invalidateQueries({ queryKey: ['pe-list'] })
|
qc.invalidateQueries({ queryKey: ['pe-list'] })
|
||||||
|
qc.invalidateQueries({ queryKey: ['pe-dept-approvals', evaluation.id] })
|
||||||
setTarget(null)
|
setTarget(null)
|
||||||
setComment('')
|
setComment('')
|
||||||
},
|
},
|
||||||
@ -116,6 +129,12 @@ export function PeWorkflowPanel({ evaluation }: { evaluation: PeDetailBundle })
|
|||||||
</Dialog>
|
</Dialog>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{deptApprovals.length > 0 && (
|
||||||
|
<div className="border-t border-slate-200 pt-4">
|
||||||
|
<DeptApprovalsSection rows={deptApprovals} currentPhase={evaluation.phase} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="border-t border-slate-200 pt-4">
|
<div className="border-t border-slate-200 pt-4">
|
||||||
<PeApprovalsSection ev={evaluation} />
|
<PeApprovalsSection ev={evaluation} />
|
||||||
</div>
|
</div>
|
||||||
@ -127,6 +146,85 @@ export function PeWorkflowPanel({ evaluation }: { evaluation: PeDetailBundle })
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 2-stage dept approval timeline (Migration 16). Group by phase × dept,
|
||||||
|
// show Review NV row + Confirm TPB row. Highlight pending khi current phase
|
||||||
|
// có Review nhưng chưa Confirm → user biết "đang chờ TPB confirm".
|
||||||
|
function DeptApprovalsSection({
|
||||||
|
rows,
|
||||||
|
currentPhase,
|
||||||
|
}: {
|
||||||
|
rows: PeDepartmentApproval[]
|
||||||
|
currentPhase: number
|
||||||
|
}) {
|
||||||
|
// Group: phase → dept → stages
|
||||||
|
const grouped = new Map<number, Map<string, PeDepartmentApproval[]>>()
|
||||||
|
for (const r of rows) {
|
||||||
|
if (!grouped.has(r.phaseAtApproval)) grouped.set(r.phaseAtApproval, new Map())
|
||||||
|
const byDept = grouped.get(r.phaseAtApproval)!
|
||||||
|
if (!byDept.has(r.departmentId)) byDept.set(r.departmentId, [])
|
||||||
|
byDept.get(r.departmentId)!.push(r)
|
||||||
|
}
|
||||||
|
// Order: phase asc
|
||||||
|
const phaseOrder = [...grouped.keys()].sort((a, b) => a - b)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h3 className="text-sm font-semibold text-slate-900">Tiến trình duyệt 2-cấp phòng ban</h3>
|
||||||
|
<p className="mt-0.5 text-[11px] text-slate-500">NV Review → TPB Confirm. Phase chỉ chuyển khi có Confirm.</p>
|
||||||
|
<div className="mt-2 space-y-3">
|
||||||
|
{phaseOrder.map(phase => {
|
||||||
|
const byDept = grouped.get(phase)!
|
||||||
|
return (
|
||||||
|
<div key={phase}>
|
||||||
|
<div className={cn(
|
||||||
|
'inline-block rounded px-1.5 py-0.5 text-[10px] font-medium',
|
||||||
|
PurchaseEvaluationPhaseColor[phase],
|
||||||
|
)}>
|
||||||
|
{PurchaseEvaluationPhaseLabel[phase]}
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 space-y-1.5">
|
||||||
|
{[...byDept.entries()].map(([deptId, stages]) => {
|
||||||
|
const review = stages.find(s => s.stage === ApprovalStage.Review)
|
||||||
|
const confirm = stages.find(s => s.stage === ApprovalStage.Confirm)
|
||||||
|
const deptName = stages[0]?.departmentName ?? '(không rõ phòng)'
|
||||||
|
const isPending = phase === currentPhase && review && !confirm
|
||||||
|
return (
|
||||||
|
<div key={deptId} className={cn(
|
||||||
|
'rounded border px-2 py-1.5 text-[11px]',
|
||||||
|
isPending ? 'border-amber-300 bg-amber-50' : 'border-slate-200 bg-slate-50',
|
||||||
|
)}>
|
||||||
|
<div className="font-medium text-slate-700">{deptName}</div>
|
||||||
|
<div className="mt-1 grid grid-cols-[60px_1fr] gap-x-2 gap-y-0.5">
|
||||||
|
<span className="text-slate-500">Review:</span>
|
||||||
|
<span className={review ? 'text-slate-700' : 'text-slate-400'}>
|
||||||
|
{review
|
||||||
|
? <>✓ {review.approverName} <span className="text-slate-500">— {fmtTime(review.approvedAt)}</span>{review.comment && <span className="text-slate-500"> · "{review.comment}"</span>}</>
|
||||||
|
: '— chưa có'}
|
||||||
|
</span>
|
||||||
|
<span className="text-slate-500">Confirm:</span>
|
||||||
|
<span className={confirm ? 'text-emerald-700' : 'text-amber-700'}>
|
||||||
|
{confirm
|
||||||
|
? <>✓ {confirm.approverName}{confirm.isBypassed && <span className="ml-1 rounded bg-fuchsia-100 px-1 text-[9px] text-fuchsia-700">bypass</span>} <span className="text-slate-500">— {fmtTime(confirm.approvedAt)}</span>{confirm.comment && <span className="text-slate-500"> · "{confirm.comment}"</span>}</>
|
||||||
|
: '⏳ chờ TPB confirm'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtTime(iso: string): string {
|
||||||
|
const d = new Date(iso)
|
||||||
|
return d.toLocaleString('vi-VN', { day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit' })
|
||||||
|
}
|
||||||
|
|
||||||
function isPastPhase(current: number, p: number, active: number[]): boolean {
|
function isPastPhase(current: number, p: number, active: number[]): boolean {
|
||||||
const orderedIdx = active.indexOf(p)
|
const orderedIdx = active.indexOf(p)
|
||||||
const currentIdx = active.indexOf(current)
|
const currentIdx = active.indexOf(current)
|
||||||
|
|||||||
@ -201,6 +201,34 @@ export type PeDepartmentOpinion = {
|
|||||||
userName: string | null
|
userName: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 2-stage department approval (Migration 16) — Stage 1=Review NV, 2=Confirm TPB.
|
||||||
|
// BLOCK transition khi NV review chưa có TPB confirm cùng (PE, Phase, Dept).
|
||||||
|
// CanBypassReview=true → NV được Stage=Confirm + IsBypassed=true (skip Review).
|
||||||
|
export const ApprovalStage = {
|
||||||
|
Review: 1,
|
||||||
|
Confirm: 2,
|
||||||
|
} as const
|
||||||
|
export type ApprovalStage = typeof ApprovalStage[keyof typeof ApprovalStage]
|
||||||
|
|
||||||
|
export const ApprovalStageLabel: Record<number, string> = {
|
||||||
|
1: 'Review NV',
|
||||||
|
2: 'Confirm TPB',
|
||||||
|
}
|
||||||
|
|
||||||
|
export type PeDepartmentApproval = {
|
||||||
|
id: string
|
||||||
|
phaseAtApproval: number
|
||||||
|
departmentId: string
|
||||||
|
departmentName: string | null
|
||||||
|
stage: number // 1=Review, 2=Confirm
|
||||||
|
approverUserId: string
|
||||||
|
approverName: string | null
|
||||||
|
approverRoleSnapshot: string | null // "TPB" | "NV" | "NV(bypass)"
|
||||||
|
comment: string | null
|
||||||
|
approvedAt: string
|
||||||
|
isBypassed: boolean
|
||||||
|
}
|
||||||
|
|
||||||
export type PeDetailBundle = {
|
export type PeDetailBundle = {
|
||||||
id: string
|
id: string
|
||||||
maPhieu: string | null
|
maPhieu: string | null
|
||||||
|
|||||||
@ -3,7 +3,7 @@
|
|||||||
// action button. Approvals + History moved here from PeDetailTabs (2 section
|
// action button. Approvals + History moved here from PeDetailTabs (2 section
|
||||||
// dưới cùng) để Panel 2 tập trung hiển thị nội dung phiếu (Info + NCC + Items).
|
// dưới cùng) để Panel 2 tập trung hiển thị nội dung phiếu (Info + NCC + Items).
|
||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
import { Dialog } from '@/components/ui/Dialog'
|
import { Dialog } from '@/components/ui/Dialog'
|
||||||
import { Button } from '@/components/ui/Button'
|
import { Button } from '@/components/ui/Button'
|
||||||
@ -13,9 +13,11 @@ import { api } from '@/lib/api'
|
|||||||
import { getErrorMessage } from '@/lib/apiError'
|
import { getErrorMessage } from '@/lib/apiError'
|
||||||
import { cn } from '@/lib/cn'
|
import { cn } from '@/lib/cn'
|
||||||
import {
|
import {
|
||||||
|
ApprovalStage,
|
||||||
PurchaseEvaluationPhase,
|
PurchaseEvaluationPhase,
|
||||||
PurchaseEvaluationPhaseColor,
|
PurchaseEvaluationPhaseColor,
|
||||||
PurchaseEvaluationPhaseLabel,
|
PurchaseEvaluationPhaseLabel,
|
||||||
|
type PeDepartmentApproval,
|
||||||
type PeDetailBundle,
|
type PeDetailBundle,
|
||||||
} from '@/types/purchaseEvaluation'
|
} from '@/types/purchaseEvaluation'
|
||||||
import { PeApprovalsSection, PeHistorySection } from './PeDetailTabs'
|
import { PeApprovalsSection, PeHistorySection } from './PeDetailTabs'
|
||||||
@ -25,6 +27,16 @@ export function PeWorkflowPanel({ evaluation }: { evaluation: PeDetailBundle })
|
|||||||
const [comment, setComment] = useState('')
|
const [comment, setComment] = useState('')
|
||||||
const qc = useQueryClient()
|
const qc = useQueryClient()
|
||||||
|
|
||||||
|
// 2-stage dept approvals (Migration 16) — fetch riêng để FE Workflow Panel
|
||||||
|
// hiển thị progress per phase × dept (Stage Review NV / Confirm TPB).
|
||||||
|
const { data: deptApprovals = [] } = useQuery<PeDepartmentApproval[]>({
|
||||||
|
queryKey: ['pe-dept-approvals', evaluation.id],
|
||||||
|
queryFn: async () => {
|
||||||
|
const r = await api.get(`/purchase-evaluations/${evaluation.id}/department-approvals`)
|
||||||
|
return r.data
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
const transition = useMutation({
|
const transition = useMutation({
|
||||||
mutationFn: async () =>
|
mutationFn: async () =>
|
||||||
api.post(`/purchase-evaluations/${evaluation.id}/transitions`, {
|
api.post(`/purchase-evaluations/${evaluation.id}/transitions`, {
|
||||||
@ -36,6 +48,7 @@ export function PeWorkflowPanel({ evaluation }: { evaluation: PeDetailBundle })
|
|||||||
toast.success('Đã chuyển phase.')
|
toast.success('Đã chuyển phase.')
|
||||||
qc.invalidateQueries({ queryKey: ['pe-detail', evaluation.id] })
|
qc.invalidateQueries({ queryKey: ['pe-detail', evaluation.id] })
|
||||||
qc.invalidateQueries({ queryKey: ['pe-list'] })
|
qc.invalidateQueries({ queryKey: ['pe-list'] })
|
||||||
|
qc.invalidateQueries({ queryKey: ['pe-dept-approvals', evaluation.id] })
|
||||||
setTarget(null)
|
setTarget(null)
|
||||||
setComment('')
|
setComment('')
|
||||||
},
|
},
|
||||||
@ -116,6 +129,12 @@ export function PeWorkflowPanel({ evaluation }: { evaluation: PeDetailBundle })
|
|||||||
</Dialog>
|
</Dialog>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{deptApprovals.length > 0 && (
|
||||||
|
<div className="border-t border-slate-200 pt-4">
|
||||||
|
<DeptApprovalsSection rows={deptApprovals} currentPhase={evaluation.phase} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="border-t border-slate-200 pt-4">
|
<div className="border-t border-slate-200 pt-4">
|
||||||
<PeApprovalsSection ev={evaluation} />
|
<PeApprovalsSection ev={evaluation} />
|
||||||
</div>
|
</div>
|
||||||
@ -127,6 +146,85 @@ export function PeWorkflowPanel({ evaluation }: { evaluation: PeDetailBundle })
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 2-stage dept approval timeline (Migration 16). Group by phase × dept,
|
||||||
|
// show Review NV row + Confirm TPB row. Highlight pending khi current phase
|
||||||
|
// có Review nhưng chưa Confirm → user biết "đang chờ TPB confirm".
|
||||||
|
function DeptApprovalsSection({
|
||||||
|
rows,
|
||||||
|
currentPhase,
|
||||||
|
}: {
|
||||||
|
rows: PeDepartmentApproval[]
|
||||||
|
currentPhase: number
|
||||||
|
}) {
|
||||||
|
// Group: phase → dept → stages
|
||||||
|
const grouped = new Map<number, Map<string, PeDepartmentApproval[]>>()
|
||||||
|
for (const r of rows) {
|
||||||
|
if (!grouped.has(r.phaseAtApproval)) grouped.set(r.phaseAtApproval, new Map())
|
||||||
|
const byDept = grouped.get(r.phaseAtApproval)!
|
||||||
|
if (!byDept.has(r.departmentId)) byDept.set(r.departmentId, [])
|
||||||
|
byDept.get(r.departmentId)!.push(r)
|
||||||
|
}
|
||||||
|
// Order: phase asc
|
||||||
|
const phaseOrder = [...grouped.keys()].sort((a, b) => a - b)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h3 className="text-sm font-semibold text-slate-900">Tiến trình duyệt 2-cấp phòng ban</h3>
|
||||||
|
<p className="mt-0.5 text-[11px] text-slate-500">NV Review → TPB Confirm. Phase chỉ chuyển khi có Confirm.</p>
|
||||||
|
<div className="mt-2 space-y-3">
|
||||||
|
{phaseOrder.map(phase => {
|
||||||
|
const byDept = grouped.get(phase)!
|
||||||
|
return (
|
||||||
|
<div key={phase}>
|
||||||
|
<div className={cn(
|
||||||
|
'inline-block rounded px-1.5 py-0.5 text-[10px] font-medium',
|
||||||
|
PurchaseEvaluationPhaseColor[phase],
|
||||||
|
)}>
|
||||||
|
{PurchaseEvaluationPhaseLabel[phase]}
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 space-y-1.5">
|
||||||
|
{[...byDept.entries()].map(([deptId, stages]) => {
|
||||||
|
const review = stages.find(s => s.stage === ApprovalStage.Review)
|
||||||
|
const confirm = stages.find(s => s.stage === ApprovalStage.Confirm)
|
||||||
|
const deptName = stages[0]?.departmentName ?? '(không rõ phòng)'
|
||||||
|
const isPending = phase === currentPhase && review && !confirm
|
||||||
|
return (
|
||||||
|
<div key={deptId} className={cn(
|
||||||
|
'rounded border px-2 py-1.5 text-[11px]',
|
||||||
|
isPending ? 'border-amber-300 bg-amber-50' : 'border-slate-200 bg-slate-50',
|
||||||
|
)}>
|
||||||
|
<div className="font-medium text-slate-700">{deptName}</div>
|
||||||
|
<div className="mt-1 grid grid-cols-[60px_1fr] gap-x-2 gap-y-0.5">
|
||||||
|
<span className="text-slate-500">Review:</span>
|
||||||
|
<span className={review ? 'text-slate-700' : 'text-slate-400'}>
|
||||||
|
{review
|
||||||
|
? <>✓ {review.approverName} <span className="text-slate-500">— {fmtTime(review.approvedAt)}</span>{review.comment && <span className="text-slate-500"> · "{review.comment}"</span>}</>
|
||||||
|
: '— chưa có'}
|
||||||
|
</span>
|
||||||
|
<span className="text-slate-500">Confirm:</span>
|
||||||
|
<span className={confirm ? 'text-emerald-700' : 'text-amber-700'}>
|
||||||
|
{confirm
|
||||||
|
? <>✓ {confirm.approverName}{confirm.isBypassed && <span className="ml-1 rounded bg-fuchsia-100 px-1 text-[9px] text-fuchsia-700">bypass</span>} <span className="text-slate-500">— {fmtTime(confirm.approvedAt)}</span>{confirm.comment && <span className="text-slate-500"> · "{confirm.comment}"</span>}</>
|
||||||
|
: '⏳ chờ TPB confirm'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtTime(iso: string): string {
|
||||||
|
const d = new Date(iso)
|
||||||
|
return d.toLocaleString('vi-VN', { day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit' })
|
||||||
|
}
|
||||||
|
|
||||||
function isPastPhase(current: number, p: number, active: number[]): boolean {
|
function isPastPhase(current: number, p: number, active: number[]): boolean {
|
||||||
const orderedIdx = active.indexOf(p)
|
const orderedIdx = active.indexOf(p)
|
||||||
const currentIdx = active.indexOf(current)
|
const currentIdx = active.indexOf(current)
|
||||||
|
|||||||
@ -201,6 +201,34 @@ export type PeDepartmentOpinion = {
|
|||||||
userName: string | null
|
userName: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 2-stage department approval (Migration 16) — Stage 1=Review NV, 2=Confirm TPB.
|
||||||
|
// BLOCK transition khi NV review chưa có TPB confirm cùng (PE, Phase, Dept).
|
||||||
|
// CanBypassReview=true → NV được Stage=Confirm + IsBypassed=true (skip Review).
|
||||||
|
export const ApprovalStage = {
|
||||||
|
Review: 1,
|
||||||
|
Confirm: 2,
|
||||||
|
} as const
|
||||||
|
export type ApprovalStage = typeof ApprovalStage[keyof typeof ApprovalStage]
|
||||||
|
|
||||||
|
export const ApprovalStageLabel: Record<number, string> = {
|
||||||
|
1: 'Review NV',
|
||||||
|
2: 'Confirm TPB',
|
||||||
|
}
|
||||||
|
|
||||||
|
export type PeDepartmentApproval = {
|
||||||
|
id: string
|
||||||
|
phaseAtApproval: number
|
||||||
|
departmentId: string
|
||||||
|
departmentName: string | null
|
||||||
|
stage: number // 1=Review, 2=Confirm
|
||||||
|
approverUserId: string
|
||||||
|
approverName: string | null
|
||||||
|
approverRoleSnapshot: string | null // "TPB" | "NV" | "NV(bypass)"
|
||||||
|
comment: string | null
|
||||||
|
approvedAt: string
|
||||||
|
isBypassed: boolean
|
||||||
|
}
|
||||||
|
|
||||||
export type PeDetailBundle = {
|
export type PeDetailBundle = {
|
||||||
id: string
|
id: string
|
||||||
maPhieu: string | null
|
maPhieu: string | null
|
||||||
|
|||||||
Reference in New Issue
Block a user