[CLAUDE] FE-PE: Chunk C — Section Ý kiến gộp đồng cấp cùng Phòng (1 box / Step)
All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 2m54s

Restructure Section 5 (rename Section 4 sau Chunk B) "Ý kiến cấp duyệt".
User Session 20 Q3=a: gộp các comment đồng cấp cùng Phòng → 1 ô / bước
(dù bước có nhiều người), CHỈ hiển thị comment của NV đã duyệt.

Trước (Mig 26 S19 LevelOpinionsSectionV2):
  forEach step → grid-cols-2 cho forEach Level × forEach Approver → 1 box / NV
  Hiển thị cả NV chưa duyệt với placeholder "— chưa duyệt"

Sau (Chunk C):
  forEach step → 1 StepOpinionsBox (đại diện Phòng)
  Box body: filter opinions có stepOrder == step.order
    → sort theo levelOrder asc, signedAt asc
    → render StepOpinionEntry per signed opinion
  NV chưa duyệt KHÔNG hiển thị
  Header box: "Bước N — Tên · {dept badge} · X/Y đã duyệt"

FE (mirror fe-admin + fe-user):
  - LevelOpinionsSectionV2 forEach step → StepOpinionsBox (replace grid-cols-2)
  - StepOpinionsBox: header phòng + body list signed opinions
  - StepOpinionEntry: tên NV + Cấp badge + Admin override badge nếu có
    + timestamp + comment
  - Drop LevelOpinionBox function (per-NV pattern bỏ)
  - KHÔNG đụng schema Mig 26 (PE Service ApproveV2Async UPSERT giữ 1 row /
    Level — chỉ FE re-group render)

Verify:
  - npm run build × fe-admin pass · fe-user pass
  - Test pass mặc định skip (Phase 9 UAT iteration, Q4 user public luôn)

Pending Chunk D: Docs S20 changelog + STATUS + HANDOFF

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
pqhuy1987
2026-05-11 10:07:02 +07:00
parent 2bba851135
commit f2f01f4765
2 changed files with 136 additions and 142 deletions

View File

@ -391,6 +391,10 @@ function OpinionBox({
// 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.
// Session 20 Chunk C: gộp opinions đồng cấp cùng Phòng → 1 box / Step.
// Trước: 1 box / NV (mỗi Level × mỗi approver = 1 OpinionBox).
// Bây giờ: 1 box / Step (Phòng), chỉ hiển thị các NV ĐÃ duyệt — opinion entries
// sort theo Cấp tăng dần. NV chưa duyệt KHÔNG hiển thị (user yêu cầu Q3=a).
function LevelOpinionsSectionV2({ ev }: { ev: PeDetailBundle }) {
const flow = ev.approvalFlow
const opinions = ev.levelOpinions
@ -404,98 +408,91 @@ function LevelOpinionsSectionV2({ ev }: { ev: PeDetailBundle }) {
}
return (
<div className="space-y-4">
<div className="space-y-3">
{flow.steps.map(step => {
const totalApprovers = step.levels.reduce((n, l) => n + l.approvers.length, 0)
const stepOpinions = opinions
.filter(o => o.stepOrder === step.order)
.slice()
.sort((a, b) => a.levelOrder - b.levelOrder || a.signedAt.localeCompare(b.signedAt))
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}
<StepOpinionsBox
key={step.order}
stepOrder={step.order}
stepName={step.name}
departmentName={step.departmentName}
totalApprovers={totalApprovers}
opinions={stepOpinions}
/>
)
}),
)}
</div>
</div>
)
})}
</div>
)
}
function LevelOpinionBox({
levelOrder,
approverUserId,
approverName,
opinion,
function StepOpinionsBox({
stepOrder, stepName, departmentName, totalApprovers, opinions,
}: {
levelOrder: number
approverUserId: string
approverName: string
opinion: PeLevelOpinion | null
stepOrder: number
stepName: string
departmentName?: string | null
totalApprovers: number
opinions: PeLevelOpinion[]
}) {
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
<div className="rounded-lg border border-slate-200 bg-white">
<div className="flex flex-wrap items-center gap-2 border-b border-slate-100 bg-slate-50/60 px-3 py-2">
<span className="text-[12px] font-bold uppercase tracking-wide text-slate-700">
Bước {stepOrder} {stepName}
</span>
{departmentName && (
<span className="rounded bg-emerald-50 px-1.5 py-0.5 text-[10px] font-medium text-emerald-700">
{departmentName}
</span>
)}
<span className="ml-auto text-[10px] text-slate-500">
{opinions.length}/{totalApprovers} đã 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 className="p-3">
{opinions.length === 0 ? (
<div className="text-[12px] italic text-slate-400"> Chưa ý kiến duyệt.</div>
) : (
<div className="space-y-2">
{opinions.map(o => <StepOpinionEntry key={o.id} opinion={o} />)}
</div>
)}
</div>
</div>
)
}
function StepOpinionEntry({ opinion }: { opinion: PeLevelOpinion }) {
const isAdminOverride = opinion.signedByUserId !== opinion.approverUserId
return (
<div className="rounded border border-emerald-100 bg-emerald-50/40 p-2.5">
<div className="mb-1 flex items-start justify-between gap-2">
<div className="min-w-0 flex-1">
<div className="text-[12px] font-semibold text-slate-900">
{opinion.approverFullName}
<span className="ml-2 rounded bg-slate-200 px-1 py-0.5 text-[10px] font-medium text-slate-700">
Cấp {opinion.levelOrder}
</span>
</div>
{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>
<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" /> {new Date(opinion.signedAt).toLocaleString('vi-VN')}
</span>
</div>
<div className="whitespace-pre-wrap text-[13px] text-slate-800">
{opinion.comment}
</div>
</div>
)
}

View File

@ -391,6 +391,10 @@ function OpinionBox({
// 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.
// Session 20 Chunk C: gộp opinions đồng cấp cùng Phòng → 1 box / Step.
// Trước: 1 box / NV (mỗi Level × mỗi approver = 1 OpinionBox).
// Bây giờ: 1 box / Step (Phòng), chỉ hiển thị các NV ĐÃ duyệt — opinion entries
// sort theo Cấp tăng dần. NV chưa duyệt KHÔNG hiển thị (user yêu cầu Q3=a).
function LevelOpinionsSectionV2({ ev }: { ev: PeDetailBundle }) {
const flow = ev.approvalFlow
const opinions = ev.levelOpinions
@ -404,98 +408,91 @@ function LevelOpinionsSectionV2({ ev }: { ev: PeDetailBundle }) {
}
return (
<div className="space-y-4">
<div className="space-y-3">
{flow.steps.map(step => {
const totalApprovers = step.levels.reduce((n, l) => n + l.approvers.length, 0)
const stepOpinions = opinions
.filter(o => o.stepOrder === step.order)
.slice()
.sort((a, b) => a.levelOrder - b.levelOrder || a.signedAt.localeCompare(b.signedAt))
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}
<StepOpinionsBox
key={step.order}
stepOrder={step.order}
stepName={step.name}
departmentName={step.departmentName}
totalApprovers={totalApprovers}
opinions={stepOpinions}
/>
)
}),
)}
</div>
</div>
)
})}
</div>
)
}
function LevelOpinionBox({
levelOrder,
approverUserId,
approverName,
opinion,
function StepOpinionsBox({
stepOrder, stepName, departmentName, totalApprovers, opinions,
}: {
levelOrder: number
approverUserId: string
approverName: string
opinion: PeLevelOpinion | null
stepOrder: number
stepName: string
departmentName?: string | null
totalApprovers: number
opinions: PeLevelOpinion[]
}) {
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
<div className="rounded-lg border border-slate-200 bg-white">
<div className="flex flex-wrap items-center gap-2 border-b border-slate-100 bg-slate-50/60 px-3 py-2">
<span className="text-[12px] font-bold uppercase tracking-wide text-slate-700">
Bước {stepOrder} {stepName}
</span>
{departmentName && (
<span className="rounded bg-emerald-50 px-1.5 py-0.5 text-[10px] font-medium text-emerald-700">
{departmentName}
</span>
)}
<span className="ml-auto text-[10px] text-slate-500">
{opinions.length}/{totalApprovers} đã 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 className="p-3">
{opinions.length === 0 ? (
<div className="text-[12px] italic text-slate-400"> Chưa ý kiến duyệt.</div>
) : (
<div className="space-y-2">
{opinions.map(o => <StepOpinionEntry key={o.id} opinion={o} />)}
</div>
)}
</div>
</div>
)
}
function StepOpinionEntry({ opinion }: { opinion: PeLevelOpinion }) {
const isAdminOverride = opinion.signedByUserId !== opinion.approverUserId
return (
<div className="rounded border border-emerald-100 bg-emerald-50/40 p-2.5">
<div className="mb-1 flex items-start justify-between gap-2">
<div className="min-w-0 flex-1">
<div className="text-[12px] font-semibold text-slate-900">
{opinion.approverFullName}
<span className="ml-2 rounded bg-slate-200 px-1 py-0.5 text-[10px] font-medium text-slate-700">
Cấp {opinion.levelOrder}
</span>
</div>
{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>
<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" /> {new Date(opinion.signedAt).toLocaleString('vi-VN')}
</span>
</div>
<div className="whitespace-pre-wrap text-[13px] text-slate-800">
{opinion.comment}
</div>
</div>
)
}