[CLAUDE] FE-PE: Chunk C Section 5 V2 dynamic theo ApprovalWorkflowLevel
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:
pqhuy1987
2026-05-09 11:05:03 +07:00
parent 90baa8e73c
commit 6e913b37a1
4 changed files with 296 additions and 6 deletions

View File

@ -33,6 +33,7 @@ import {
type PeDepartmentOpinion, type PeDepartmentOpinion,
type PeDetailBundle, type PeDetailBundle,
type PeDetailRow, type PeDetailRow,
type PeLevelOpinion,
type PeQuote, type PeQuote,
type PeSupplier, type PeSupplier,
} from '@/types/purchaseEvaluation' } from '@/types/purchaseEvaluation'
@ -173,13 +174,17 @@ export function PeDetailTabs({
<Section title={`4. Hạng mục + Báo giá (${evaluation.details.length})`}> <Section title={`4. Hạng mục + Báo giá (${evaluation.details.length})`}>
<ItemsTab ev={evaluation} readOnly={readOnly} /> <ItemsTab ev={evaluation} readOnly={readOnly} />
</Section> </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' && ( {mode === 'workspace' && (
<div className="mb-3 rounded border border-amber-200 bg-amber-50 px-3 py-2 text-[12px] text-amber-800"> <div className="mb-3 rounded border border-amber-200 bg-amber-50 px-3 py-2 text-[12px] text-amber-800">
Ý kiến + chữ nhập khi duyệt phiếu vào menu &ldquo;Duyệt&rdquo; đ . Ý kiến + chữ auto đng bộ khi NV duyệt phiếu vào menu &ldquo;Duyệt&rdquo; đ .
</div> </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> </Section>
</div> </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ấ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 ===== // ===== Exports cho Panel 3 — Approvals history + Changelog =====
export function PeApprovalsSection({ ev }: { ev: PeDetailBundle }) { export function PeApprovalsSection({ ev }: { ev: PeDetailBundle }) {

View File

@ -298,6 +298,27 @@ export type PeDepartmentOpinion = {
userName: string | null 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. // 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). // 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). // CanBypassReview=true → NV được Stage=Confirm + IsBypassed=true (skip Review).
@ -366,5 +387,7 @@ export type PeDetailBundle = {
approvals: PeApproval[] approvals: PeApproval[]
attachments: PeAttachment[] attachments: PeAttachment[]
departmentOpinions: PeDepartmentOpinion[] departmentOpinions: PeDepartmentOpinion[]
// Mig 26 — Section 5 V2 dynamic. Empty cho V1 / V2 chưa có cấp duyệt.
levelOpinions: PeLevelOpinion[]
workflow: PeWorkflowSummary workflow: PeWorkflowSummary
} }

View File

@ -33,6 +33,7 @@ import {
type PeDepartmentOpinion, type PeDepartmentOpinion,
type PeDetailBundle, type PeDetailBundle,
type PeDetailRow, type PeDetailRow,
type PeLevelOpinion,
type PeQuote, type PeQuote,
type PeSupplier, type PeSupplier,
} from '@/types/purchaseEvaluation' } from '@/types/purchaseEvaluation'
@ -173,13 +174,17 @@ export function PeDetailTabs({
<Section title={`4. Hạng mục + Báo giá (${evaluation.details.length})`}> <Section title={`4. Hạng mục + Báo giá (${evaluation.details.length})`}>
<ItemsTab ev={evaluation} readOnly={readOnly} /> <ItemsTab ev={evaluation} readOnly={readOnly} />
</Section> </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' && ( {mode === 'workspace' && (
<div className="mb-3 rounded border border-amber-200 bg-amber-50 px-3 py-2 text-[12px] text-amber-800"> <div className="mb-3 rounded border border-amber-200 bg-amber-50 px-3 py-2 text-[12px] text-amber-800">
Ý kiến + chữ nhập khi duyệt phiếu vào menu &ldquo;Duyệt&rdquo; đ . Ý kiến + chữ auto đng bộ khi NV duyệt phiếu vào menu &ldquo;Duyệt&rdquo; đ .
</div> </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> </Section>
</div> </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ấ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 ===== // ===== Exports cho Panel 3 — Approvals history + Changelog =====
export function PeApprovalsSection({ ev }: { ev: PeDetailBundle }) { export function PeApprovalsSection({ ev }: { ev: PeDetailBundle }) {

View File

@ -295,6 +295,27 @@ export type PeDepartmentOpinion = {
userName: string | null 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. // 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). // 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). // CanBypassReview=true → NV được Stage=Confirm + IsBypassed=true (skip Review).
@ -363,5 +384,7 @@ export type PeDetailBundle = {
approvals: PeApproval[] approvals: PeApproval[]
attachments: PeAttachment[] attachments: PeAttachment[]
departmentOpinions: PeDepartmentOpinion[] departmentOpinions: PeDepartmentOpinion[]
// Mig 26 — Section 5 V2 dynamic. Empty cho V1 / V2 chưa có cấp duyệt.
levelOpinions: PeLevelOpinion[]
workflow: PeWorkflowSummary workflow: PeWorkflowSummary
} }