[CLAUDE] FE-PE: Section Ý kiến revise — ô vuông cards grid-cols-2 + counter Cấp đúng semantic
All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 3m11s

User feedback Session 20 turn 2:
1. "Chỗ ý kiến vẫn hiển thị ô vuông như trước nhé" — revert visual về cards
   grid-cols-2 mirror S19 (Chunk C cũ dùng vertical list inline không phải
   ô vuông như trước).
2. "Số bước duyệt khác số người duyệt trong 1 bước, check lại" — counter cũ
   `{opinions.length}/{totalApprovers}` sai semantic vì OR-of-N (mỗi Cấp chỉ
   cần 1 NV ký, không cần ký tất cả NV). totalApprovers đếm tổng NV gây hiểu
   lầm.

Fix (FE-only mirror fe-admin + fe-user):
- StepOpinionsBox body chuyển từ `space-y-2` (vertical list) sang
  `grid grid-cols-1 md:grid-cols-2 gap-3` — mỗi opinion = 1 card đầy đủ
  border-emerald-200 + bg-white + p-3 (mirror visual S19 LevelOpinionBox).
- StepOpinionEntry restore styling đầy đủ:
  - Header: "Cấp N — Tên NV" font-semibold + admin override badge amber +
    "✓ Đã duyệt" emerald rounded-full badge
  - Body: comment text-sm
  - Footer: signedAt border-t separator (như S19)
- Counter mới: `{signedLevels}/{totalLevels} cấp đã duyệt · {totalApprovers}
  NV tham gia` — đếm Cấp distinct (Set unique levelOrder) thay vì count NV.
  Tooltip giải thích "OR-of-N" cho user hiểu.
- KHÔNG đụng schema Mig 26 (vẫn UPSERT 1 row / Level qua Service).

Verify:
- npm run build × fe-admin pass
- npm run build × fe-user pass
- Test pass mặc định skip (Q4 UAT iteration)

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

View File

@ -391,10 +391,13 @@ function OpinionBox({
// Layout 5A: header "Bước N — Phòng X" badge + grid-cols-2 cho N approvers // 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. // (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. // Session 20 Chunk C (revised): gộp opinions đồng cấp cùng Phòng → 1 wrapper box / Step,
// Trước: 1 box / NV (mỗi Level × mỗi approver = 1 OpinionBox). // BÊN TRONG render từng NV đã duyệt thành các "ô vuông" card mirror visual S19
// Bây giờ: 1 box / Step (Phòng), chỉ hiển thị các NV ĐÃ duyệt — opinion entries // (grid-cols-2 cards). User feedback turn 2: giữ visual ô vuông như trước.
// 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). //
// Counter fix turn 2: "Số bước duyệt" (= số Cấp / Step) KHÁC "số người duyệt trong
// 1 bước" (= tổng NV across Cấp, OR-of-N nên chỉ 1 NV/Cấp cần ký). Counter đúng
// hiển thị X/Y cấp đã duyệt + thông tin phụ tổng NV tham gia.
function LevelOpinionsSectionV2({ ev }: { ev: PeDetailBundle }) { function LevelOpinionsSectionV2({ ev }: { ev: PeDetailBundle }) {
const flow = ev.approvalFlow const flow = ev.approvalFlow
const opinions = ev.levelOpinions const opinions = ev.levelOpinions
@ -410,18 +413,22 @@ function LevelOpinionsSectionV2({ ev }: { ev: PeDetailBundle }) {
return ( return (
<div className="space-y-3"> <div className="space-y-3">
{flow.steps.map(step => { {flow.steps.map(step => {
const totalLevels = step.levels.length
const totalApprovers = step.levels.reduce((n, l) => n + l.approvers.length, 0) const totalApprovers = step.levels.reduce((n, l) => n + l.approvers.length, 0)
const stepOpinions = opinions const stepOpinions = opinions
.filter(o => o.stepOrder === step.order) .filter(o => o.stepOrder === step.order)
.slice() .slice()
.sort((a, b) => a.levelOrder - b.levelOrder || a.signedAt.localeCompare(b.signedAt)) .sort((a, b) => a.levelOrder - b.levelOrder || a.signedAt.localeCompare(b.signedAt))
const signedLevels = new Set(stepOpinions.map(o => o.levelOrder)).size
return ( return (
<StepOpinionsBox <StepOpinionsBox
key={step.order} key={step.order}
stepOrder={step.order} stepOrder={step.order}
stepName={step.name} stepName={step.name}
departmentName={step.departmentName} departmentName={step.departmentName}
totalLevels={totalLevels}
totalApprovers={totalApprovers} totalApprovers={totalApprovers}
signedLevels={signedLevels}
opinions={stepOpinions} opinions={stepOpinions}
/> />
) )
@ -431,17 +438,19 @@ function LevelOpinionsSectionV2({ ev }: { ev: PeDetailBundle }) {
} }
function StepOpinionsBox({ function StepOpinionsBox({
stepOrder, stepName, departmentName, totalApprovers, opinions, stepOrder, stepName, departmentName, totalLevels, totalApprovers, signedLevels, opinions,
}: { }: {
stepOrder: number stepOrder: number
stepName: string stepName: string
departmentName?: string | null departmentName?: string | null
totalApprovers: number totalLevels: number // số Cấp (bước duyệt nhỏ trong Step)
totalApprovers: number // tổng NV tham gia (FYI — OR-of-N nên không cần ký hết)
signedLevels: number // số Cấp đã có ít nhất 1 NV ký
opinions: PeLevelOpinion[] opinions: PeLevelOpinion[]
}) { }) {
return ( return (
<div className="rounded-lg border border-slate-200 bg-white"> <div className="rounded-lg border border-slate-200 bg-slate-50/40">
<div className="flex flex-wrap items-center gap-2 border-b border-slate-100 bg-slate-50/60 px-3 py-2"> <div className="flex flex-wrap items-center gap-2 border-b border-slate-200 bg-slate-50/80 px-3 py-2">
<span className="text-[12px] font-bold uppercase tracking-wide text-slate-700"> <span className="text-[12px] font-bold uppercase tracking-wide text-slate-700">
Bước {stepOrder} {stepName} Bước {stepOrder} {stepName}
</span> </span>
@ -450,15 +459,18 @@ function StepOpinionsBox({
{departmentName} {departmentName}
</span> </span>
)} )}
<span className="ml-auto text-[10px] text-slate-500"> <span
{opinions.length}/{totalApprovers} đã duyệt className="ml-auto text-[10px] text-slate-500"
title={`Tổng ${totalApprovers} NV tham gia (mỗi cấp chỉ cần 1 NV ký — OR-of-N)`}
>
{signedLevels}/{totalLevels} cấp đã duyệt · {totalApprovers} NV tham gia
</span> </span>
</div> </div>
<div className="p-3"> <div className="p-3">
{opinions.length === 0 ? ( {opinions.length === 0 ? (
<div className="text-[12px] italic text-slate-400"> Chưa ý kiến duyệt.</div> <div className="text-[12px] italic text-slate-400"> Chưa ý kiến duyệt.</div>
) : ( ) : (
<div className="space-y-2"> <div className="grid grid-cols-1 gap-3 md:grid-cols-2">
{opinions.map(o => <StepOpinionEntry key={o.id} opinion={o} />)} {opinions.map(o => <StepOpinionEntry key={o.id} opinion={o} />)}
</div> </div>
)} )}
@ -470,15 +482,12 @@ function StepOpinionsBox({
function StepOpinionEntry({ opinion }: { opinion: PeLevelOpinion }) { function StepOpinionEntry({ opinion }: { opinion: PeLevelOpinion }) {
const isAdminOverride = opinion.signedByUserId !== opinion.approverUserId const isAdminOverride = opinion.signedByUserId !== opinion.approverUserId
return ( return (
<div className="rounded border border-emerald-100 bg-emerald-50/40 p-2.5"> <div className="rounded-lg border border-emerald-200 bg-white p-3">
<div className="mb-1 flex items-start justify-between gap-2"> <div className="mb-2 flex items-start justify-between gap-2">
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<div className="text-[12px] font-semibold text-slate-900"> <h4 className="text-[13px] font-semibold text-slate-700">
{opinion.approverFullName} Cấp {opinion.levelOrder} <span className="text-slate-900">{opinion.approverFullName}</span>
<span className="ml-2 rounded bg-slate-200 px-1 py-0.5 text-[10px] font-medium text-slate-700"> </h4>
Cấp {opinion.levelOrder}
</span>
</div>
{isAdminOverride && ( {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"> <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 Admin <strong>{opinion.signedByFullName}</strong> duyệt thay
@ -486,12 +495,15 @@ function StepOpinionEntry({ opinion }: { opinion: PeLevelOpinion }) {
)} )}
</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"> <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')} <Check className="h-3 w-3" /> Đã duyệt
</span> </span>
</div> </div>
<div className="whitespace-pre-wrap text-[13px] text-slate-800"> <div className="whitespace-pre-wrap text-sm text-slate-800">
{opinion.comment} {opinion.comment}
</div> </div>
<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> </div>
) )
} }

View File

@ -391,10 +391,13 @@ function OpinionBox({
// Layout 5A: header "Bước N — Phòng X" badge + grid-cols-2 cho N approvers // 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. // (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. // Session 20 Chunk C (revised): gộp opinions đồng cấp cùng Phòng → 1 wrapper box / Step,
// Trước: 1 box / NV (mỗi Level × mỗi approver = 1 OpinionBox). // BÊN TRONG render từng NV đã duyệt thành các "ô vuông" card mirror visual S19
// Bây giờ: 1 box / Step (Phòng), chỉ hiển thị các NV ĐÃ duyệt — opinion entries // (grid-cols-2 cards). User feedback turn 2: giữ visual ô vuông như trước.
// 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). //
// Counter fix turn 2: "Số bước duyệt" (= số Cấp / Step) KHÁC "số người duyệt trong
// 1 bước" (= tổng NV across Cấp, OR-of-N nên chỉ 1 NV/Cấp cần ký). Counter đúng
// hiển thị X/Y cấp đã duyệt + thông tin phụ tổng NV tham gia.
function LevelOpinionsSectionV2({ ev }: { ev: PeDetailBundle }) { function LevelOpinionsSectionV2({ ev }: { ev: PeDetailBundle }) {
const flow = ev.approvalFlow const flow = ev.approvalFlow
const opinions = ev.levelOpinions const opinions = ev.levelOpinions
@ -410,18 +413,22 @@ function LevelOpinionsSectionV2({ ev }: { ev: PeDetailBundle }) {
return ( return (
<div className="space-y-3"> <div className="space-y-3">
{flow.steps.map(step => { {flow.steps.map(step => {
const totalLevels = step.levels.length
const totalApprovers = step.levels.reduce((n, l) => n + l.approvers.length, 0) const totalApprovers = step.levels.reduce((n, l) => n + l.approvers.length, 0)
const stepOpinions = opinions const stepOpinions = opinions
.filter(o => o.stepOrder === step.order) .filter(o => o.stepOrder === step.order)
.slice() .slice()
.sort((a, b) => a.levelOrder - b.levelOrder || a.signedAt.localeCompare(b.signedAt)) .sort((a, b) => a.levelOrder - b.levelOrder || a.signedAt.localeCompare(b.signedAt))
const signedLevels = new Set(stepOpinions.map(o => o.levelOrder)).size
return ( return (
<StepOpinionsBox <StepOpinionsBox
key={step.order} key={step.order}
stepOrder={step.order} stepOrder={step.order}
stepName={step.name} stepName={step.name}
departmentName={step.departmentName} departmentName={step.departmentName}
totalLevels={totalLevels}
totalApprovers={totalApprovers} totalApprovers={totalApprovers}
signedLevels={signedLevels}
opinions={stepOpinions} opinions={stepOpinions}
/> />
) )
@ -431,17 +438,19 @@ function LevelOpinionsSectionV2({ ev }: { ev: PeDetailBundle }) {
} }
function StepOpinionsBox({ function StepOpinionsBox({
stepOrder, stepName, departmentName, totalApprovers, opinions, stepOrder, stepName, departmentName, totalLevels, totalApprovers, signedLevels, opinions,
}: { }: {
stepOrder: number stepOrder: number
stepName: string stepName: string
departmentName?: string | null departmentName?: string | null
totalApprovers: number totalLevels: number // số Cấp (bước duyệt nhỏ trong Step)
totalApprovers: number // tổng NV tham gia (FYI — OR-of-N nên không cần ký hết)
signedLevels: number // số Cấp đã có ít nhất 1 NV ký
opinions: PeLevelOpinion[] opinions: PeLevelOpinion[]
}) { }) {
return ( return (
<div className="rounded-lg border border-slate-200 bg-white"> <div className="rounded-lg border border-slate-200 bg-slate-50/40">
<div className="flex flex-wrap items-center gap-2 border-b border-slate-100 bg-slate-50/60 px-3 py-2"> <div className="flex flex-wrap items-center gap-2 border-b border-slate-200 bg-slate-50/80 px-3 py-2">
<span className="text-[12px] font-bold uppercase tracking-wide text-slate-700"> <span className="text-[12px] font-bold uppercase tracking-wide text-slate-700">
Bước {stepOrder} {stepName} Bước {stepOrder} {stepName}
</span> </span>
@ -450,15 +459,18 @@ function StepOpinionsBox({
{departmentName} {departmentName}
</span> </span>
)} )}
<span className="ml-auto text-[10px] text-slate-500"> <span
{opinions.length}/{totalApprovers} đã duyệt className="ml-auto text-[10px] text-slate-500"
title={`Tổng ${totalApprovers} NV tham gia (mỗi cấp chỉ cần 1 NV ký — OR-of-N)`}
>
{signedLevels}/{totalLevels} cấp đã duyệt · {totalApprovers} NV tham gia
</span> </span>
</div> </div>
<div className="p-3"> <div className="p-3">
{opinions.length === 0 ? ( {opinions.length === 0 ? (
<div className="text-[12px] italic text-slate-400"> Chưa ý kiến duyệt.</div> <div className="text-[12px] italic text-slate-400"> Chưa ý kiến duyệt.</div>
) : ( ) : (
<div className="space-y-2"> <div className="grid grid-cols-1 gap-3 md:grid-cols-2">
{opinions.map(o => <StepOpinionEntry key={o.id} opinion={o} />)} {opinions.map(o => <StepOpinionEntry key={o.id} opinion={o} />)}
</div> </div>
)} )}
@ -470,15 +482,12 @@ function StepOpinionsBox({
function StepOpinionEntry({ opinion }: { opinion: PeLevelOpinion }) { function StepOpinionEntry({ opinion }: { opinion: PeLevelOpinion }) {
const isAdminOverride = opinion.signedByUserId !== opinion.approverUserId const isAdminOverride = opinion.signedByUserId !== opinion.approverUserId
return ( return (
<div className="rounded border border-emerald-100 bg-emerald-50/40 p-2.5"> <div className="rounded-lg border border-emerald-200 bg-white p-3">
<div className="mb-1 flex items-start justify-between gap-2"> <div className="mb-2 flex items-start justify-between gap-2">
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<div className="text-[12px] font-semibold text-slate-900"> <h4 className="text-[13px] font-semibold text-slate-700">
{opinion.approverFullName} Cấp {opinion.levelOrder} <span className="text-slate-900">{opinion.approverFullName}</span>
<span className="ml-2 rounded bg-slate-200 px-1 py-0.5 text-[10px] font-medium text-slate-700"> </h4>
Cấp {opinion.levelOrder}
</span>
</div>
{isAdminOverride && ( {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"> <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 Admin <strong>{opinion.signedByFullName}</strong> duyệt thay
@ -486,12 +495,15 @@ function StepOpinionEntry({ opinion }: { opinion: PeLevelOpinion }) {
)} )}
</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"> <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')} <Check className="h-3 w-3" /> Đã duyệt
</span> </span>
</div> </div>
<div className="whitespace-pre-wrap text-[13px] text-slate-800"> <div className="whitespace-pre-wrap text-sm text-slate-800">
{opinion.comment} {opinion.comment}
</div> </div>
<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> </div>
) )
} }