[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
|
autoEditHeader?: boolean
|
||||||
}) {
|
}) {
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
|
const qc = useQueryClient()
|
||||||
// isDraft renamed → canEditPhase: bao gồm cả TraLai (per user 2026-05-07).
|
// 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.
|
// Header bar action buttons (Sửa header / Xóa) hiện khi phase editable + !readOnly.
|
||||||
const canEditPhase = isEditablePhase(evaluation.phase)
|
const canEditPhase = isEditablePhase(evaluation.phase)
|
||||||
const opinionsReadOnly = readOnly || mode === 'workspace'
|
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 (
|
return (
|
||||||
<div className="rounded-lg border border-slate-200 bg-white shadow-sm">
|
<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">
|
<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} />
|
<DepartmentOpinionsSection ev={evaluation} readOnly={opinionsReadOnly} />
|
||||||
</Section>
|
</Section>
|
||||||
</div>
|
</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>
|
</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) =====
|
// ===== 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
|
// 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 /
|
// đ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 (
|
return (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<FormRow
|
<NccSelectorRow ev={ev} readOnly={readOnly} />
|
||||||
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>}
|
|
||||||
/>
|
|
||||||
<BudgetFieldRow ev={ev} readOnly={readOnly} />
|
<BudgetFieldRow ev={ev} readOnly={readOnly} />
|
||||||
<FormRow
|
<FormRow
|
||||||
label="c. Giá chào thầu"
|
label="c. Giá chào thầu"
|
||||||
value={giaChaoThau != null ? (
|
value={
|
||||||
<span className="font-semibold text-slate-900">{giaChaoThau.toLocaleString('vi-VN')} đ</span>
|
winnerSupplierRowId === null ? (
|
||||||
) : <span className="text-slate-400">— (chưa chọn NCC / chưa nhập báo giá)</span>}
|
<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>
|
||||||
<div className="flex gap-3">
|
<div className="flex gap-3">
|
||||||
@ -810,38 +922,48 @@ function SuppliersTab({ ev, readOnly = false }: { ev: PeDetailBundle; readOnly?:
|
|||||||
readOnly={readOnly}
|
readOnly={readOnly}
|
||||||
/>
|
/>
|
||||||
</td>
|
</td>
|
||||||
{!readOnly && (
|
{!readOnly && (() => {
|
||||||
<td className="px-3 py-2">
|
// User 2026-05-07: NCC đã được chọn (winner) → KHÔNG cho
|
||||||
<div className="flex justify-end gap-1">
|
// sửa/xóa (tránh thay đổi NCC đã chốt). Chỉ hiển thị
|
||||||
<button
|
// checkmark active state.
|
||||||
onClick={() => setWinner.mutate(s.supplierId)}
|
const isWinner = ev.selectedSupplierId === s.supplierId
|
||||||
className={cn(
|
return (
|
||||||
'rounded px-1.5 py-0.5 text-[11px]',
|
<td className="px-3 py-2">
|
||||||
ev.selectedSupplierId === s.supplierId
|
<div className="flex justify-end gap-1">
|
||||||
? 'bg-emerald-100 text-emerald-700'
|
<button
|
||||||
: 'text-slate-500 hover:bg-emerald-50 hover:text-emerald-700',
|
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"
|
</div>
|
||||||
>
|
</td>
|
||||||
<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>
|
|
||||||
)}
|
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
|
|||||||
@ -70,11 +70,39 @@ export function PeDetailTabs({
|
|||||||
autoEditHeader?: boolean
|
autoEditHeader?: boolean
|
||||||
}) {
|
}) {
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
|
const qc = useQueryClient()
|
||||||
// isDraft renamed → canEditPhase: bao gồm cả TraLai (per user 2026-05-07).
|
// 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.
|
// Header bar action buttons (Sửa header / Xóa) hiện khi phase editable + !readOnly.
|
||||||
const canEditPhase = isEditablePhase(evaluation.phase)
|
const canEditPhase = isEditablePhase(evaluation.phase)
|
||||||
const opinionsReadOnly = readOnly || mode === 'workspace'
|
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 (
|
return (
|
||||||
<div className="rounded-lg border border-slate-200 bg-white shadow-sm">
|
<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">
|
<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} />
|
<DepartmentOpinionsSection ev={evaluation} readOnly={opinionsReadOnly} />
|
||||||
</Section>
|
</Section>
|
||||||
</div>
|
</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>
|
</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) =====
|
// ===== 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
|
// 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 /
|
// đ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 (
|
return (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<FormRow
|
<NccSelectorRow ev={ev} readOnly={readOnly} />
|
||||||
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>}
|
|
||||||
/>
|
|
||||||
<BudgetFieldRow ev={ev} readOnly={readOnly} />
|
<BudgetFieldRow ev={ev} readOnly={readOnly} />
|
||||||
<FormRow
|
<FormRow
|
||||||
label="c. Giá chào thầu"
|
label="c. Giá chào thầu"
|
||||||
value={giaChaoThau != null ? (
|
value={
|
||||||
<span className="font-semibold text-slate-900">{giaChaoThau.toLocaleString('vi-VN')} đ</span>
|
winnerSupplierRowId === null ? (
|
||||||
) : <span className="text-slate-400">— (chưa chọn NCC / chưa nhập báo giá)</span>}
|
<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>
|
||||||
<div className="flex gap-3">
|
<div className="flex gap-3">
|
||||||
@ -810,38 +922,48 @@ function SuppliersTab({ ev, readOnly = false }: { ev: PeDetailBundle; readOnly?:
|
|||||||
readOnly={readOnly}
|
readOnly={readOnly}
|
||||||
/>
|
/>
|
||||||
</td>
|
</td>
|
||||||
{!readOnly && (
|
{!readOnly && (() => {
|
||||||
<td className="px-3 py-2">
|
// User 2026-05-07: NCC đã được chọn (winner) → KHÔNG cho
|
||||||
<div className="flex justify-end gap-1">
|
// sửa/xóa (tránh thay đổi NCC đã chốt). Chỉ hiển thị
|
||||||
<button
|
// checkmark active state.
|
||||||
onClick={() => setWinner.mutate(s.supplierId)}
|
const isWinner = ev.selectedSupplierId === s.supplierId
|
||||||
className={cn(
|
return (
|
||||||
'rounded px-1.5 py-0.5 text-[11px]',
|
<td className="px-3 py-2">
|
||||||
ev.selectedSupplierId === s.supplierId
|
<div className="flex justify-end gap-1">
|
||||||
? 'bg-emerald-100 text-emerald-700'
|
<button
|
||||||
: 'text-slate-500 hover:bg-emerald-50 hover:text-emerald-700',
|
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"
|
</div>
|
||||||
>
|
</td>
|
||||||
<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>
|
|
||||||
)}
|
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
|
|||||||
Reference in New Issue
Block a user