[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
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:
@ -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 có ý kiến duyệt.</div>
|
<div className="text-[12px] italic text-slate-400">— Chưa có ý 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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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 có ý kiến duyệt.</div>
|
<div className="text-[12px] italic text-slate-400">— Chưa có ý 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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user