[CLAUDE] FE-Admin+FE-User: PE detail Section 2 + 3 tweak + bottom action bar Lưu/Gửi Duyệt
All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 3m9s
All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 3m9s
User feedback 2026-05-07 (annotation screenshot):
1. "a. NCC/TP được chọn" → dropdown picker chọn từ Section 3 list (canEdit only)
2. "c. Giá chào thầu" → tách 2 message rõ:
- Chưa chọn NCC → "(chọn NCC/TP ở (a) trước)"
- Đã chọn nhưng chưa có quotes → "(chưa nhập báo giá ở Section 4)"
- Có quotes → số tiền
3. Section 3 NCC tham gia row → khi NCC là winner (selected): KHÔNG cho sửa/xóa
(chỉ giữ icon ✓ active state, ẩn ✏ + 🗑 buttons)
4. Workspace mode bottom action bar: 2 nút "Lưu (đóng)" + "Lưu & Gửi Duyệt →"
- Lưu: invokes onBack (đóng workspace, các thay đổi đã auto-save inline)
- Lưu & Gửi Duyệt: confirm dialog → POST /transitions với targetPhase = first
nextPhase (skip TuChoi/TraLai) → toast + invalidate + onBack
→ workflow chuyển từ Bản nháp/Trả lại → Đã gửi duyệt (ChoPurchasing thường)
Implementation:
~ PeDetailTabs.tsx (× 2 app, mirror y hệt)
+ NccSelectorRow component (~50 LOC) — Select dropdown tích hợp /select-winner
endpoint hiện có. Read-only mode: hiển thị FormRow như cũ. Disable khi
ev.suppliers empty + hint "Thêm NCC ở Section 3 trước".
~ ChonNccSection: thay <FormRow "a. NCC"> → <NccSelectorRow>. Cải tiến text
"c. Giá chào thầu" empty state.
~ SuppliersTab row actions: wrap conditional isWinner = ev.selectedSupplierId
=== s.supplierId. !isWinner → render Pencil + Trash. isWinner → chỉ Check
icon active state.
~ PeDetailTabs root: + qc useQueryClient + submitForApproval mutation +
canSubmitForApproval flag. Bottom action bar hiển thị khi mode='workspace'
+ canEditPhase + !readOnly.
Verify: npm run build fe-admin + fe-user pass · 0 TS error · áp rule strict
verify (lesson hotfix CI 0ae3fe2 — luôn build trước commit khi có new code).
UAT mode: skip dotnet test (FE-only changes), push ngay.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@ -70,11 +70,39 @@ export function PeDetailTabs({
|
||||
autoEditHeader?: boolean
|
||||
}) {
|
||||
const navigate = useNavigate()
|
||||
const qc = useQueryClient()
|
||||
// isDraft renamed → canEditPhase: bao gồm cả TraLai (per user 2026-05-07).
|
||||
// Header bar action buttons (Sửa header / Xóa) hiện khi phase editable + !readOnly.
|
||||
const canEditPhase = isEditablePhase(evaluation.phase)
|
||||
const opinionsReadOnly = readOnly || mode === 'workspace'
|
||||
|
||||
// "Lưu & Gửi Duyệt" workspace mode (user 2026-05-07): trigger transition
|
||||
// sang phase tiếp theo (= Đã gửi duyệt). nextPhases[0] thường là ChoPurchasing
|
||||
// (skip TuChoi). Sau success → toast + invalidate + onBack đóng workspace.
|
||||
const submitForApproval = useMutation({
|
||||
mutationFn: async () => {
|
||||
const next = evaluation.workflow.nextPhases.find(p => p !== PurchaseEvaluationPhase.TuChoi && p !== PurchaseEvaluationPhase.TraLai)
|
||||
if (!next) throw new Error('Không có phase tiếp theo để gửi duyệt')
|
||||
return api.post(`/purchase-evaluations/${evaluation.id}/transitions`, {
|
||||
targetPhase: next,
|
||||
decision: 1,
|
||||
comment: null,
|
||||
})
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast.success('Đã gửi duyệt phiếu — chuyển sang quy trình duyệt.')
|
||||
qc.invalidateQueries({ queryKey: ['pe-detail', evaluation.id] })
|
||||
qc.invalidateQueries({ queryKey: ['pe-list'] })
|
||||
onBack()
|
||||
},
|
||||
onError: e => toast.error(getErrorMessage(e)),
|
||||
})
|
||||
|
||||
const canSubmitForApproval = mode === 'workspace'
|
||||
&& canEditPhase
|
||||
&& !readOnly
|
||||
&& evaluation.workflow.nextPhases.some(p => p !== PurchaseEvaluationPhase.TuChoi && p !== PurchaseEvaluationPhase.TraLai)
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border border-slate-200 bg-white shadow-sm">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2 border-b border-slate-200 px-5 py-3">
|
||||
@ -147,6 +175,33 @@ export function PeDetailTabs({
|
||||
<DepartmentOpinionsSection ev={evaluation} readOnly={opinionsReadOnly} />
|
||||
</Section>
|
||||
</div>
|
||||
|
||||
{/* Action bar bottom — workspace mode + canEdit + !readOnly. 2 nút Lưu
|
||||
(đóng workspace, các thay đổi đã auto-save inline) + Lưu & Gửi Duyệt
|
||||
(POST /transitions → next phase, vào quy trình duyệt). User 2026-05-07. */}
|
||||
{mode === 'workspace' && canEditPhase && !readOnly && (
|
||||
<div className="flex flex-wrap items-center justify-between gap-2 border-t border-slate-200 bg-slate-50 px-5 py-3">
|
||||
<div className="text-[11px] text-slate-500">
|
||||
✓ Các thay đổi đã tự động lưu khi chỉnh sửa từng phần.
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="ghost" onClick={onBack} className="text-xs">
|
||||
Lưu (đóng)
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => {
|
||||
if (confirm('Gửi phiếu vào quy trình duyệt? Sau khi gửi sẽ KHÔNG sửa được nữa (trừ khi approver Trả lại).')) {
|
||||
submitForApproval.mutate()
|
||||
}
|
||||
}}
|
||||
disabled={!canSubmitForApproval || submitForApproval.isPending}
|
||||
className="text-xs"
|
||||
>
|
||||
{submitForApproval.isPending ? 'Đang gửi…' : 'Lưu & Gửi Duyệt →'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@ -443,6 +498,62 @@ function InfoTab({ ev, readOnly, autoEdit }: { ev: PeDetailBundle; readOnly: boo
|
||||
)
|
||||
}
|
||||
|
||||
// ===== a. NCC / TP được chọn — dropdown picker (user 2026-05-07) =====
|
||||
// Workspace + canEdit phase: render Select dropdown từ ev.suppliers (Section 3
|
||||
// tham gia list). Read-only: hiển thị "✓ Tên NCC" hoặc "(chưa chọn)".
|
||||
// Save dùng POST /pe/:id/select-winner endpoint hiện có.
|
||||
function NccSelectorRow({ ev, readOnly }: { ev: PeDetailBundle; readOnly: boolean }) {
|
||||
const canEdit = !readOnly && isEditablePhase(ev.phase)
|
||||
const qc = useQueryClient()
|
||||
const setWinner = useMutation({
|
||||
mutationFn: async (supplierId: string) =>
|
||||
api.post(`/purchase-evaluations/${ev.id}/select-winner`, { supplierId }),
|
||||
onSuccess: () => {
|
||||
toast.success('Đã chọn NCC.')
|
||||
qc.invalidateQueries({ queryKey: ['pe-detail', ev.id] })
|
||||
qc.invalidateQueries({ queryKey: ['pe-list'] })
|
||||
},
|
||||
onError: e => toast.error(getErrorMessage(e)),
|
||||
})
|
||||
|
||||
if (!canEdit) {
|
||||
return (
|
||||
<FormRow
|
||||
label="a. NCC / TP được chọn"
|
||||
value={ev.selectedSupplierName
|
||||
? <span className="font-medium text-emerald-700">✓ {ev.selectedSupplierName}</span>
|
||||
: <span className="text-slate-400">— (chưa chọn)</span>}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-baseline gap-3 border-b border-dotted border-slate-200 pb-1.5">
|
||||
<span className="w-44 shrink-0 text-[12px] text-slate-500">a. NCC / TP được chọn</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<Select
|
||||
value={ev.selectedSupplierId ?? ''}
|
||||
onChange={e => setWinner.mutate(e.target.value)}
|
||||
disabled={ev.suppliers.length === 0 || setWinner.isPending}
|
||||
className="text-sm"
|
||||
>
|
||||
<option value="">— Chọn NCC từ danh sách Section 3 —</option>
|
||||
{ev.suppliers.map(s => (
|
||||
<option key={s.id} value={s.supplierId}>
|
||||
{s.supplierName}{s.displayName ? ` — ${s.displayName}` : ''}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
{ev.suppliers.length === 0 && (
|
||||
<p className="mt-1 text-[11px] text-amber-600">
|
||||
Thêm NCC ở Section 3 trước rồi mới chọn winner.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ===== b. Ngân sách inline editor (Mig 17) =====
|
||||
// Hiển thị + edit budget link / manual fields ngay trong Section 2 — KHÔNG cần
|
||||
// đi tới "Sửa header" page. Visible trong cả 3 view (Workspace / Danh sách /
|
||||
@ -617,18 +728,19 @@ function ChonNccSection({ ev, readOnly = false }: { ev: PeDetailBundle; readOnly
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<FormRow
|
||||
label="a. NCC / TP được chọn"
|
||||
value={ev.selectedSupplierName ? (
|
||||
<span className="font-medium text-emerald-700">✓ {ev.selectedSupplierName}</span>
|
||||
) : <span className="text-slate-400">— (chưa chọn)</span>}
|
||||
/>
|
||||
<NccSelectorRow ev={ev} readOnly={readOnly} />
|
||||
<BudgetFieldRow ev={ev} readOnly={readOnly} />
|
||||
<FormRow
|
||||
label="c. Giá chào thầu"
|
||||
value={giaChaoThau != null ? (
|
||||
<span className="font-semibold text-slate-900">{giaChaoThau.toLocaleString('vi-VN')} đ</span>
|
||||
) : <span className="text-slate-400">— (chưa chọn NCC / chưa nhập báo giá)</span>}
|
||||
value={
|
||||
winnerSupplierRowId === null ? (
|
||||
<span className="text-slate-400">— (chọn NCC/TP ở (a) trước)</span>
|
||||
) : giaChaoThau === 0 ? (
|
||||
<span className="text-slate-400">— (chưa nhập báo giá ở Section 4)</span>
|
||||
) : (
|
||||
<span className="font-semibold text-slate-900">{giaChaoThau!.toLocaleString('vi-VN')} đ</span>
|
||||
)
|
||||
}
|
||||
/>
|
||||
<div>
|
||||
<div className="flex gap-3">
|
||||
@ -810,38 +922,48 @@ function SuppliersTab({ ev, readOnly = false }: { ev: PeDetailBundle; readOnly?:
|
||||
readOnly={readOnly}
|
||||
/>
|
||||
</td>
|
||||
{!readOnly && (
|
||||
<td className="px-3 py-2">
|
||||
<div className="flex justify-end gap-1">
|
||||
<button
|
||||
onClick={() => setWinner.mutate(s.supplierId)}
|
||||
className={cn(
|
||||
'rounded px-1.5 py-0.5 text-[11px]',
|
||||
ev.selectedSupplierId === s.supplierId
|
||||
? 'bg-emerald-100 text-emerald-700'
|
||||
: 'text-slate-500 hover:bg-emerald-50 hover:text-emerald-700',
|
||||
{!readOnly && (() => {
|
||||
// User 2026-05-07: NCC đã được chọn (winner) → KHÔNG cho
|
||||
// sửa/xóa (tránh thay đổi NCC đã chốt). Chỉ hiển thị
|
||||
// checkmark active state.
|
||||
const isWinner = ev.selectedSupplierId === s.supplierId
|
||||
return (
|
||||
<td className="px-3 py-2">
|
||||
<div className="flex justify-end gap-1">
|
||||
<button
|
||||
onClick={() => setWinner.mutate(s.supplierId)}
|
||||
className={cn(
|
||||
'rounded px-1.5 py-0.5 text-[11px]',
|
||||
isWinner
|
||||
? 'bg-emerald-100 text-emerald-700'
|
||||
: 'text-slate-500 hover:bg-emerald-50 hover:text-emerald-700',
|
||||
)}
|
||||
title={isWinner ? 'NCC đã được chọn (winner)' : 'Chọn NCC thắng'}
|
||||
>
|
||||
<Check className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
{!isWinner && (
|
||||
<>
|
||||
<button
|
||||
onClick={() => setEditRow(s)}
|
||||
className="rounded px-1.5 py-0.5 text-slate-500 hover:bg-slate-100"
|
||||
title="Sửa"
|
||||
>
|
||||
<Pencil className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { if (confirm('Xóa NCC này khỏi phiếu?')) remove.mutate(s.id) }}
|
||||
className="rounded px-1.5 py-0.5 text-red-500 hover:bg-red-50"
|
||||
title="Xóa"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
title="Chọn NCC thắng"
|
||||
>
|
||||
<Check className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setEditRow(s)}
|
||||
className="rounded px-1.5 py-0.5 text-slate-500 hover:bg-slate-100"
|
||||
title="Sửa"
|
||||
>
|
||||
<Pencil className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { if (confirm('Xóa NCC này khỏi phiếu?')) remove.mutate(s.id) }}
|
||||
className="rounded px-1.5 py-0.5 text-red-500 hover:bg-red-50"
|
||||
title="Xóa"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
)
|
||||
})()}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
|
||||
Reference in New Issue
Block a user