[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
|
||||
// (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).
|
||||
// Session 20 Chunk C (revised): gộp opinions đồng cấp cùng Phòng → 1 wrapper box / Step,
|
||||
// BÊN TRONG render từng NV đã duyệt thành các "ô vuông" card mirror visual S19
|
||||
// (grid-cols-2 cards). User feedback turn 2: giữ visual ô vuông như trước.
|
||||
//
|
||||
// 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 }) {
|
||||
const flow = ev.approvalFlow
|
||||
const opinions = ev.levelOpinions
|
||||
@ -410,18 +413,22 @@ function LevelOpinionsSectionV2({ ev }: { ev: PeDetailBundle }) {
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{flow.steps.map(step => {
|
||||
const totalLevels = step.levels.length
|
||||
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))
|
||||
const signedLevels = new Set(stepOpinions.map(o => o.levelOrder)).size
|
||||
return (
|
||||
<StepOpinionsBox
|
||||
key={step.order}
|
||||
stepOrder={step.order}
|
||||
stepName={step.name}
|
||||
departmentName={step.departmentName}
|
||||
totalLevels={totalLevels}
|
||||
totalApprovers={totalApprovers}
|
||||
signedLevels={signedLevels}
|
||||
opinions={stepOpinions}
|
||||
/>
|
||||
)
|
||||
@ -431,17 +438,19 @@ function LevelOpinionsSectionV2({ ev }: { ev: PeDetailBundle }) {
|
||||
}
|
||||
|
||||
function StepOpinionsBox({
|
||||
stepOrder, stepName, departmentName, totalApprovers, opinions,
|
||||
stepOrder, stepName, departmentName, totalLevels, totalApprovers, signedLevels, opinions,
|
||||
}: {
|
||||
stepOrder: number
|
||||
stepName: string
|
||||
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[]
|
||||
}) {
|
||||
return (
|
||||
<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">
|
||||
<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-200 bg-slate-50/80 px-3 py-2">
|
||||
<span className="text-[12px] font-bold uppercase tracking-wide text-slate-700">
|
||||
Bước {stepOrder} — {stepName}
|
||||
</span>
|
||||
@ -450,15 +459,18 @@ function StepOpinionsBox({
|
||||
{departmentName}
|
||||
</span>
|
||||
)}
|
||||
<span className="ml-auto text-[10px] text-slate-500">
|
||||
{opinions.length}/{totalApprovers} đã duyệt
|
||||
<span
|
||||
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>
|
||||
</div>
|
||||
<div className="p-3">
|
||||
{opinions.length === 0 ? (
|
||||
<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} />)}
|
||||
</div>
|
||||
)}
|
||||
@ -470,15 +482,12 @@ function StepOpinionsBox({
|
||||
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="rounded-lg border border-emerald-200 bg-white p-3">
|
||||
<div className="mb-2 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>
|
||||
<h4 className="text-[13px] font-semibold text-slate-700">
|
||||
Cấp {opinion.levelOrder} — <span className="text-slate-900">{opinion.approverFullName}</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
|
||||
@ -486,12 +495,15 @@ function StepOpinionEntry({ opinion }: { opinion: PeLevelOpinion }) {
|
||||
)}
|
||||
</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')}
|
||||
<Check className="h-3 w-3" /> Đã duyệt
|
||||
</span>
|
||||
</div>
|
||||
<div className="whitespace-pre-wrap text-[13px] text-slate-800">
|
||||
<div className="whitespace-pre-wrap text-sm text-slate-800">
|
||||
{opinion.comment}
|
||||
</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>
|
||||
)
|
||||
}
|
||||
|
||||
@ -391,10 +391,13 @@ 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).
|
||||
// Session 20 Chunk C (revised): gộp opinions đồng cấp cùng Phòng → 1 wrapper box / Step,
|
||||
// BÊN TRONG render từng NV đã duyệt thành các "ô vuông" card mirror visual S19
|
||||
// (grid-cols-2 cards). User feedback turn 2: giữ visual ô vuông như trước.
|
||||
//
|
||||
// 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 }) {
|
||||
const flow = ev.approvalFlow
|
||||
const opinions = ev.levelOpinions
|
||||
@ -410,18 +413,22 @@ function LevelOpinionsSectionV2({ ev }: { ev: PeDetailBundle }) {
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{flow.steps.map(step => {
|
||||
const totalLevels = step.levels.length
|
||||
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))
|
||||
const signedLevels = new Set(stepOpinions.map(o => o.levelOrder)).size
|
||||
return (
|
||||
<StepOpinionsBox
|
||||
key={step.order}
|
||||
stepOrder={step.order}
|
||||
stepName={step.name}
|
||||
departmentName={step.departmentName}
|
||||
totalLevels={totalLevels}
|
||||
totalApprovers={totalApprovers}
|
||||
signedLevels={signedLevels}
|
||||
opinions={stepOpinions}
|
||||
/>
|
||||
)
|
||||
@ -431,17 +438,19 @@ function LevelOpinionsSectionV2({ ev }: { ev: PeDetailBundle }) {
|
||||
}
|
||||
|
||||
function StepOpinionsBox({
|
||||
stepOrder, stepName, departmentName, totalApprovers, opinions,
|
||||
stepOrder, stepName, departmentName, totalLevels, totalApprovers, signedLevels, opinions,
|
||||
}: {
|
||||
stepOrder: number
|
||||
stepName: string
|
||||
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[]
|
||||
}) {
|
||||
return (
|
||||
<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">
|
||||
<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-200 bg-slate-50/80 px-3 py-2">
|
||||
<span className="text-[12px] font-bold uppercase tracking-wide text-slate-700">
|
||||
Bước {stepOrder} — {stepName}
|
||||
</span>
|
||||
@ -450,15 +459,18 @@ function StepOpinionsBox({
|
||||
{departmentName}
|
||||
</span>
|
||||
)}
|
||||
<span className="ml-auto text-[10px] text-slate-500">
|
||||
{opinions.length}/{totalApprovers} đã duyệt
|
||||
<span
|
||||
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>
|
||||
</div>
|
||||
<div className="p-3">
|
||||
{opinions.length === 0 ? (
|
||||
<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} />)}
|
||||
</div>
|
||||
)}
|
||||
@ -470,15 +482,12 @@ function StepOpinionsBox({
|
||||
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="rounded-lg border border-emerald-200 bg-white p-3">
|
||||
<div className="mb-2 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>
|
||||
<h4 className="text-[13px] font-semibold text-slate-700">
|
||||
Cấp {opinion.levelOrder} — <span className="text-slate-900">{opinion.approverFullName}</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
|
||||
@ -486,12 +495,15 @@ function StepOpinionEntry({ opinion }: { opinion: PeLevelOpinion }) {
|
||||
)}
|
||||
</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')}
|
||||
<Check className="h-3 w-3" /> Đã duyệt
|
||||
</span>
|
||||
</div>
|
||||
<div className="whitespace-pre-wrap text-[13px] text-slate-800">
|
||||
<div className="whitespace-pre-wrap text-sm text-slate-800">
|
||||
{opinion.comment}
|
||||
</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>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user