[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
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:
@ -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 có ý 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>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@ -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 có ý 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>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user