diff --git a/fe-admin/src/components/pe/PeDetailTabs.tsx b/fe-admin/src/components/pe/PeDetailTabs.tsx index 4746bcd..f4fd7bc 100644 --- a/fe-admin/src/components/pe/PeDetailTabs.tsx +++ b/fe-admin/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. +
+
+ + +
+
+ )}
) } @@ -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 && ( - -
- + {!isWinner && ( + <> + + + )} - title="Chọn NCC thắng" - > - - - - -
- - )} +
+ + ) + })()} ))} 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. +
+
+ + +
+
+ )}
) } @@ -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 && ( - -
- + {!isWinner && ( + <> + + + )} - title="Chọn NCC thắng" - > - - - - -
- - )} +
+ + ) + })()} ))}