[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
|
// 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.
|
||||||
|
// 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 }) {
|
function LevelOpinionsSectionV2({ ev }: { ev: PeDetailBundle }) {
|
||||||
const flow = ev.approvalFlow
|
const flow = ev.approvalFlow
|
||||||
const opinions = ev.levelOpinions
|
const opinions = ev.levelOpinions
|
||||||
@ -404,97 +408,90 @@ function LevelOpinionsSectionV2({ ev }: { ev: PeDetailBundle }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-3">
|
||||||
{flow.steps.map(step => {
|
{flow.steps.map(step => {
|
||||||
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
|
||||||
|
.filter(o => o.stepOrder === step.order)
|
||||||
|
.slice()
|
||||||
|
.sort((a, b) => a.levelOrder - b.levelOrder || a.signedAt.localeCompare(b.signedAt))
|
||||||
return (
|
return (
|
||||||
<div key={step.order} className="rounded-lg border border-slate-200 bg-slate-50/50 p-3">
|
<StepOpinionsBox
|
||||||
<div className="mb-2.5 flex flex-wrap items-center gap-2">
|
key={step.order}
|
||||||
<span className="text-[12px] font-bold uppercase tracking-wide text-slate-700">
|
stepOrder={step.order}
|
||||||
Bước {step.order} — {step.name}
|
stepName={step.name}
|
||||||
</span>
|
departmentName={step.departmentName}
|
||||||
{step.departmentName && (
|
totalApprovers={totalApprovers}
|
||||||
<span className="rounded bg-emerald-50 px-1.5 py-0.5 text-[10px] font-medium text-emerald-700">
|
opinions={stepOpinions}
|
||||||
{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}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}),
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function LevelOpinionBox({
|
function StepOpinionsBox({
|
||||||
levelOrder,
|
stepOrder, stepName, departmentName, totalApprovers, opinions,
|
||||||
approverUserId,
|
|
||||||
approverName,
|
|
||||||
opinion,
|
|
||||||
}: {
|
}: {
|
||||||
levelOrder: number
|
stepOrder: number
|
||||||
approverUserId: string
|
stepName: string
|
||||||
approverName: string
|
departmentName?: string | null
|
||||||
opinion: PeLevelOpinion | null
|
totalApprovers: number
|
||||||
|
opinions: PeLevelOpinion[]
|
||||||
}) {
|
}) {
|
||||||
const isSigned = !!opinion
|
|
||||||
const isAdminOverride = isSigned && opinion!.signedByUserId !== approverUserId
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={cn(
|
<div className="rounded-lg border border-slate-200 bg-white">
|
||||||
'rounded-lg border bg-white p-3',
|
<div className="flex flex-wrap items-center gap-2 border-b border-slate-100 bg-slate-50/60 px-3 py-2">
|
||||||
isSigned ? 'border-emerald-200' : 'border-slate-200',
|
<span className="text-[12px] font-bold uppercase tracking-wide text-slate-700">
|
||||||
)}>
|
Bước {stepOrder} — {stepName}
|
||||||
<div className="mb-2 flex items-start justify-between gap-2">
|
</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="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="min-w-0 flex-1">
|
||||||
<h4 className="text-[13px] font-semibold text-slate-700">
|
<div className="text-[12px] font-semibold text-slate-900">
|
||||||
Cấp {levelOrder} — <span className="text-slate-900">{approverName}</span>
|
{opinion.approverFullName}
|
||||||
</h4>
|
<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 && (
|
{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
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</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">
|
||||||
<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="min-h-[40px] whitespace-pre-wrap text-sm text-slate-800">
|
<div className="whitespace-pre-wrap text-[13px] text-slate-800">
|
||||||
{opinion?.comment ?? <span className="italic text-slate-400">— chưa duyệt</span>}
|
{opinion.comment}
|
||||||
</div>
|
</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>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -391,6 +391,10 @@ 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.
|
||||||
|
// 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 }) {
|
function LevelOpinionsSectionV2({ ev }: { ev: PeDetailBundle }) {
|
||||||
const flow = ev.approvalFlow
|
const flow = ev.approvalFlow
|
||||||
const opinions = ev.levelOpinions
|
const opinions = ev.levelOpinions
|
||||||
@ -404,97 +408,90 @@ function LevelOpinionsSectionV2({ ev }: { ev: PeDetailBundle }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-3">
|
||||||
{flow.steps.map(step => {
|
{flow.steps.map(step => {
|
||||||
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
|
||||||
|
.filter(o => o.stepOrder === step.order)
|
||||||
|
.slice()
|
||||||
|
.sort((a, b) => a.levelOrder - b.levelOrder || a.signedAt.localeCompare(b.signedAt))
|
||||||
return (
|
return (
|
||||||
<div key={step.order} className="rounded-lg border border-slate-200 bg-slate-50/50 p-3">
|
<StepOpinionsBox
|
||||||
<div className="mb-2.5 flex flex-wrap items-center gap-2">
|
key={step.order}
|
||||||
<span className="text-[12px] font-bold uppercase tracking-wide text-slate-700">
|
stepOrder={step.order}
|
||||||
Bước {step.order} — {step.name}
|
stepName={step.name}
|
||||||
</span>
|
departmentName={step.departmentName}
|
||||||
{step.departmentName && (
|
totalApprovers={totalApprovers}
|
||||||
<span className="rounded bg-emerald-50 px-1.5 py-0.5 text-[10px] font-medium text-emerald-700">
|
opinions={stepOpinions}
|
||||||
{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}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}),
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function LevelOpinionBox({
|
function StepOpinionsBox({
|
||||||
levelOrder,
|
stepOrder, stepName, departmentName, totalApprovers, opinions,
|
||||||
approverUserId,
|
|
||||||
approverName,
|
|
||||||
opinion,
|
|
||||||
}: {
|
}: {
|
||||||
levelOrder: number
|
stepOrder: number
|
||||||
approverUserId: string
|
stepName: string
|
||||||
approverName: string
|
departmentName?: string | null
|
||||||
opinion: PeLevelOpinion | null
|
totalApprovers: number
|
||||||
|
opinions: PeLevelOpinion[]
|
||||||
}) {
|
}) {
|
||||||
const isSigned = !!opinion
|
|
||||||
const isAdminOverride = isSigned && opinion!.signedByUserId !== approverUserId
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={cn(
|
<div className="rounded-lg border border-slate-200 bg-white">
|
||||||
'rounded-lg border bg-white p-3',
|
<div className="flex flex-wrap items-center gap-2 border-b border-slate-100 bg-slate-50/60 px-3 py-2">
|
||||||
isSigned ? 'border-emerald-200' : 'border-slate-200',
|
<span className="text-[12px] font-bold uppercase tracking-wide text-slate-700">
|
||||||
)}>
|
Bước {stepOrder} — {stepName}
|
||||||
<div className="mb-2 flex items-start justify-between gap-2">
|
</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="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="min-w-0 flex-1">
|
||||||
<h4 className="text-[13px] font-semibold text-slate-700">
|
<div className="text-[12px] font-semibold text-slate-900">
|
||||||
Cấp {levelOrder} — <span className="text-slate-900">{approverName}</span>
|
{opinion.approverFullName}
|
||||||
</h4>
|
<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 && (
|
{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
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</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">
|
||||||
<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="min-h-[40px] whitespace-pre-wrap text-sm text-slate-800">
|
<div className="whitespace-pre-wrap text-[13px] text-slate-800">
|
||||||
{opinion?.comment ?? <span className="italic text-slate-400">— chưa duyệt</span>}
|
{opinion.comment}
|
||||||
</div>
|
</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>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user