@@ -810,38 +922,48 @@ function SuppliersTab({ ev, readOnly = false }: { ev: PeDetailBundle; readOnly?:
readOnly={readOnly}
/>
- {!readOnly && (
-
-
-
+ |
+ )
+ })()}
))}
diff --git a/fe-user/src/components/pe/PeDetailTabs.tsx b/fe-user/src/components/pe/PeDetailTabs.tsx
index 4746bcd..f4fd7bc 100644
--- a/fe-user/src/components/pe/PeDetailTabs.tsx
+++ b/fe-user/src/components/pe/PeDetailTabs.tsx
@@ -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 (
@@ -147,6 +175,33 @@ export function PeDetailTabs({
+
+ {/* 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 && (
+
+
+ ✓ Các thay đổi đã tự động lưu khi chỉnh sửa từng phần.
+
+
+
+ Lưu (đóng)
+
+ {
+ 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 →'}
+
+
+
+ )}
)
}
@@ -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 (
+
✓ {ev.selectedSupplierName}
+ : — (chưa chọn)}
+ />
+ )
+ }
+
+ return (
+
+
a. NCC / TP được chọn
+
+
+ {ev.suppliers.length === 0 && (
+
+ Thêm NCC ở Section 3 trước rồi mới chọn winner.
+
+ )}
+
+
+ )
+}
+
// ===== 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 (
-
✓ {ev.selectedSupplierName}
- ) : — (chưa chọn)}
- />
+
{giaChaoThau.toLocaleString('vi-VN')} đ
- ) : — (chưa chọn NCC / chưa nhập báo giá)}
+ value={
+ winnerSupplierRowId === null ? (
+ — (chọn NCC/TP ở (a) trước)
+ ) : giaChaoThau === 0 ? (
+ — (chưa nhập báo giá ở Section 4)
+ ) : (
+ {giaChaoThau!.toLocaleString('vi-VN')} đ
+ )
+ }
/>
@@ -810,38 +922,48 @@ function SuppliersTab({ ev, readOnly = false }: { ev: PeDetailBundle; readOnly?:
readOnly={readOnly}
/>
- {!readOnly && (
-
-
- 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 (
+
+
+ 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'}
+ >
+
+
+ {!isWinner && (
+ <>
+ setEditRow(s)}
+ className="rounded px-1.5 py-0.5 text-slate-500 hover:bg-slate-100"
+ title="Sửa"
+ >
+
+
+ { 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"
+ >
+
+
+ >
)}
- title="Chọn NCC thắng"
- >
-
-
- setEditRow(s)}
- className="rounded px-1.5 py-0.5 text-slate-500 hover:bg-slate-100"
- title="Sửa"
- >
-
-
- { 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"
- >
-
-
-
- |
- )}
+
+ |
+ )
+ })()}
))}