[CLAUDE] FE-PE: Chunk C Section 5 V2 dynamic theo ApprovalWorkflowLevel
All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 14m51s
All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 14m51s
Section 5 PeDetailTabs render dynamic theo workflow đã pin (V2). Thay 4 box CỨNG (PheDuyet/CCM/MuaHàng/SmPm Mig 15) cho phiếu V2. Type: `PeLevelOpinion` (15 field) + `PeDetailBundle.levelOpinions[]`. Section 5 conditional: - evaluation.approvalWorkflowId set → <LevelOpinionsSectionV2/> - V1 legacy (no awId) → <DepartmentOpinionsSection/> readOnly fallback (giữ data Mig 15) LevelOpinionsSectionV2: - Layout 5A — group theo Step (header "Bước N — <name>" + dept badge emerald) - grid-cols-2 cho approvers trong tất cả Levels của Step - Hint "(N người duyệt)" khi totalApprovers > 1 - Empty state khi flow null / 0 steps LevelOpinionBox (read-only — Q1=1B sync auto từ Workflow Panel): - Title "Cấp N — <ApproverFullName>" - Badge amber "⚠ Admin <name> duyệt thay" khi SignedByUserId !== ApproverUserId - Badge emerald "✓ Đã duyệt" khi opinion tồn tại - Empty: "— chưa duyệt" italic gray - Footer: timestamp signedAt format vi-VN Workspace mode hint giữ amber "Ý kiến + chữ ký auto đồng bộ khi NV duyệt". Mirror fe-admin + fe-user (rule §3.9). Verify: npm run build × 2 pass · 0 TS error. Chunk D kế tiếp: Docs (STATUS/HANDOFF/schema-diagram/session log).
This commit is contained in:
@ -33,6 +33,7 @@ import {
|
||||
type PeDepartmentOpinion,
|
||||
type PeDetailBundle,
|
||||
type PeDetailRow,
|
||||
type PeLevelOpinion,
|
||||
type PeQuote,
|
||||
type PeSupplier,
|
||||
} from '@/types/purchaseEvaluation'
|
||||
@ -173,13 +174,17 @@ export function PeDetailTabs({
|
||||
<Section title={`4. Hạng mục + Báo giá (${evaluation.details.length})`}>
|
||||
<ItemsTab ev={evaluation} readOnly={readOnly} />
|
||||
</Section>
|
||||
<Section title="5. Ý kiến 4 phòng ban (sign-off)">
|
||||
<Section title="5. Ý kiến cấp duyệt (sign-off theo workflow)">
|
||||
{mode === 'workspace' && (
|
||||
<div className="mb-3 rounded border border-amber-200 bg-amber-50 px-3 py-2 text-[12px] text-amber-800">
|
||||
Ý kiến + chữ ký nhập khi duyệt phiếu — vào menu “Duyệt” để ký.
|
||||
Ý kiến + chữ ký auto đồng bộ khi NV duyệt phiếu — vào menu “Duyệt” để ký.
|
||||
</div>
|
||||
)}
|
||||
<DepartmentOpinionsSection ev={evaluation} readOnly={opinionsReadOnly} />
|
||||
{/* Mig 26 — V2 dynamic theo ApprovalWorkflowLevel. V1 phiếu cũ
|
||||
fallback render 4 box CỨNG readOnly (data legacy giữ Mig 15). */}
|
||||
{evaluation.approvalWorkflowId
|
||||
? <LevelOpinionsSectionV2 ev={evaluation} />
|
||||
: <DepartmentOpinionsSection ev={evaluation} readOnly={opinionsReadOnly} />}
|
||||
</Section>
|
||||
</div>
|
||||
|
||||
@ -377,6 +382,123 @@ function OpinionBox({
|
||||
)
|
||||
}
|
||||
|
||||
// ===== Section 5 V2 — Ý kiến cấp duyệt dynamic (Mig 26 — Session 19) =====
|
||||
//
|
||||
// Render theo workflow đã pin: forEach Step → forEach Level (Cấp) → forEach
|
||||
// approver (NV). Mỗi NV = 1 OpinionBox (read-only). Service ApproveV2Async
|
||||
// auto sync comment khi duyệt (Q1=1B). Empty list → fallback message.
|
||||
//
|
||||
// Layout 5A: header "Bước N — Phòng X" badge + grid-cols-2 cho N approvers
|
||||
// (wrap nếu N>2). Admin override badge khi SignedByUserId !== ApproverUserId.
|
||||
|
||||
function LevelOpinionsSectionV2({ ev }: { ev: PeDetailBundle }) {
|
||||
const flow = ev.approvalFlow
|
||||
const opinions = ev.levelOpinions
|
||||
|
||||
if (!flow || flow.steps.length === 0) {
|
||||
return (
|
||||
<div className="rounded border border-slate-200 bg-slate-50 px-3 py-2 text-[12px] text-slate-500">
|
||||
Workflow chưa được cấu hình hoặc chưa có cấp duyệt nào.
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{flow.steps.map(step => {
|
||||
const totalApprovers = step.levels.reduce((n, l) => n + l.approvers.length, 0)
|
||||
return (
|
||||
<div key={step.order} className="rounded-lg border border-slate-200 bg-slate-50/50 p-3">
|
||||
<div className="mb-2.5 flex flex-wrap items-center gap-2">
|
||||
<span className="text-[12px] font-bold uppercase tracking-wide text-slate-700">
|
||||
Bước {step.order} — {step.name}
|
||||
</span>
|
||||
{step.departmentName && (
|
||||
<span className="rounded bg-emerald-50 px-1.5 py-0.5 text-[10px] font-medium text-emerald-700">
|
||||
{step.departmentName}
|
||||
</span>
|
||||
)}
|
||||
{totalApprovers > 1 && (
|
||||
<span className="text-[10px] text-slate-400">
|
||||
({totalApprovers} người duyệt)
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="grid grid-cols-1 gap-3 md:grid-cols-2">
|
||||
{step.levels.flatMap(level =>
|
||||
level.approvers.map(approver => {
|
||||
const opinion = opinions.find(o =>
|
||||
o.stepOrder === step.order
|
||||
&& o.levelOrder === level.order
|
||||
&& o.approverUserId === approver.userId,
|
||||
) ?? null
|
||||
return (
|
||||
<LevelOpinionBox
|
||||
key={`${step.order}-${level.order}-${approver.userId}`}
|
||||
levelOrder={level.order}
|
||||
approverUserId={approver.userId}
|
||||
approverName={approver.fullName}
|
||||
opinion={opinion}
|
||||
/>
|
||||
)
|
||||
}),
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function LevelOpinionBox({
|
||||
levelOrder,
|
||||
approverUserId,
|
||||
approverName,
|
||||
opinion,
|
||||
}: {
|
||||
levelOrder: number
|
||||
approverUserId: string
|
||||
approverName: string
|
||||
opinion: PeLevelOpinion | null
|
||||
}) {
|
||||
const isSigned = !!opinion
|
||||
const isAdminOverride = isSigned && opinion!.signedByUserId !== approverUserId
|
||||
|
||||
return (
|
||||
<div className={cn(
|
||||
'rounded-lg border bg-white p-3',
|
||||
isSigned ? 'border-emerald-200' : 'border-slate-200',
|
||||
)}>
|
||||
<div className="mb-2 flex items-start justify-between gap-2">
|
||||
<div className="min-w-0 flex-1">
|
||||
<h4 className="text-[13px] font-semibold text-slate-700">
|
||||
Cấp {levelOrder} — <span className="text-slate-900">{approverName}</span>
|
||||
</h4>
|
||||
{isAdminOverride && (
|
||||
<div className="mt-1 inline-flex items-center gap-1 rounded bg-amber-50 px-1.5 py-0.5 text-[10px] font-medium text-amber-700">
|
||||
⚠ Admin <strong>{opinion!.signedByFullName}</strong> duyệt thay
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{isSigned && (
|
||||
<span className="inline-flex shrink-0 items-center gap-1 rounded-full bg-emerald-100 px-2 py-0.5 text-[10px] font-medium text-emerald-700">
|
||||
<Check className="h-3 w-3" /> Đã duyệt
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="min-h-[40px] whitespace-pre-wrap text-sm text-slate-800">
|
||||
{opinion?.comment ?? <span className="italic text-slate-400">— chưa duyệt</span>}
|
||||
</div>
|
||||
{isSigned && (
|
||||
<div className="mt-2 border-t border-slate-100 pt-1.5 text-[11px] text-slate-500">
|
||||
{new Date(opinion!.signedAt).toLocaleString('vi-VN')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ===== Exports cho Panel 3 — Approvals history + Changelog =====
|
||||
|
||||
export function PeApprovalsSection({ ev }: { ev: PeDetailBundle }) {
|
||||
|
||||
@ -298,6 +298,27 @@ export type PeDepartmentOpinion = {
|
||||
userName: string | null
|
||||
}
|
||||
|
||||
// Mig 26 (Session 19) — Section 5 V2 dynamic theo ApprovalWorkflowLevel.
|
||||
// Service ApproveV2Async UPSERT auto khi NV duyệt (Q1=1B). Empty list cho
|
||||
// phiếu V1 / V2 chưa có cấp nào duyệt → FE fallback message.
|
||||
// `signedByUserId !== approverUserId` → FE banner "Admin duyệt thay".
|
||||
export type PeLevelOpinion = {
|
||||
id: string
|
||||
approvalWorkflowLevelId: string
|
||||
stepOrder: number
|
||||
stepName: string
|
||||
stepDepartmentId: string | null
|
||||
stepDepartmentName: string | null
|
||||
levelOrder: number
|
||||
levelName: string | null
|
||||
approverUserId: string
|
||||
approverFullName: string | null
|
||||
comment: string
|
||||
signedAt: string
|
||||
signedByUserId: string
|
||||
signedByFullName: string
|
||||
}
|
||||
|
||||
// 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).
|
||||
@ -366,5 +387,7 @@ export type PeDetailBundle = {
|
||||
approvals: PeApproval[]
|
||||
attachments: PeAttachment[]
|
||||
departmentOpinions: PeDepartmentOpinion[]
|
||||
// Mig 26 — Section 5 V2 dynamic. Empty cho V1 / V2 chưa có cấp duyệt.
|
||||
levelOpinions: PeLevelOpinion[]
|
||||
workflow: PeWorkflowSummary
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user