diff --git a/docs/operator-abort-mission-contract.md b/docs/operator-abort-mission-contract.md new file mode 100644 index 0000000..1333a89 --- /dev/null +++ b/docs/operator-abort-mission-contract.md @@ -0,0 +1,110 @@ +# SOS Rescue - Operator Abort Mission Contract + +Cập nhật: 2026-05-06 + +Tài liệu này mô tả chi tiết luồng **Điều phối viên chủ động hủy nhiệm vụ cứu hộ** (Operator Abort Mission) khi Cứu hộ viên (Rescuer) không phản hồi hoặc gặp sự cố trong quá trình di chuyển. + +## 1) Mục tiêu nghiệp vụ + +Cho phép Điều phối viên (Operator) can thiệp vào các nhiệm vụ đang thực hiện (`Preparing` hoặc `EnRoute`) để: +- Thu hồi nhiệm vụ từ Rescuer không phản hồi/di chuyển quá lâu. +- Đưa Incident quay lại trạng thái `Verified` để thực hiện điều phối lại (Re-dispatch). +- Đảm bảo Member nhận được sự hỗ trợ kịp thời từ người khác. + +--- + +## 2) API Endpoint cho Frontend (Operator Dashboard) + +Dùng để gọi từ giao diện điều phối của Operator. + +- **URL**: `PATCH /api/rescue-missions/{missionId}/operator-abort` +- **Authentication**: Yêu cầu quyền `Operator` hoặc `Admin`. +- **Request Body**: +```json +{ + "cancellationReason": "Rescuer không bắt máy, GPS không di chuyển quá 15 phút" +} +``` +- **Xử lý phía Backend**: + - Chuyển `Mission.Status` -> `MissionAborted`. + - Chuyển `Incident.Status` -> `Verified`. + - Xóa `AssignedRescuerId` của Incident. + - Đặt Rescuer hiện tại thành `IsAvailable = true`. + - Gửi thông báo tới Member, Rescuer và các Operator khác. + +--- + +## 3) SignalR Event cho Flutter (Rescuer App) + +Hub liên quan: `RescuerHub` + +### 🟢 Event: `MissionAbortedByOperator` + +Khi Operator thực hiện hủy nhiệm vụ, Rescuer đang giữ nhiệm vụ đó sẽ nhận được event này để cập nhật UI ngay lập tức. + +- **Payload**: +```json +{ + "incidentId": "guid-của-vụ-việc", + "reason": "Lý do hủy từ operator", + "message": "Nhiệm vụ của bạn đã bị điều phối viên hủy. Lý do: [reason]" +} +``` + +### 📱 Hướng dẫn xử lý trên Flutter Rescuer App: +1. **Lắng nghe event**: Đăng ký listen `MissionAbortedByOperator` trong `RescuerHub`. +2. **Cập nhật UI**: + - Hiển thị Dialog thông báo lý do bị hủy. + - Dừng luồng dẫn đường (nếu đang EnRoute). + - Đưa Rescuer quay về màn hình Trang chủ (Home) hoặc danh sách yêu cầu. +3. **Local State**: Xóa thông tin mission đang lưu cục bộ. + +--- + +## 4) SignalR Event cho Frontend (Operator Dashboard) + +Hub liên quan: `OperatorHub` + +### 🟢 Event: `RescuerAborted` + +Event này dùng chung cho cả trường hợp Rescuer tự Abort và Operator ép Abort. + +- **Payload**: +```json +{ + "incidentId": "guid", + "rescuerId": "guid", + "operatorId": "guid-của-người-abort (nếu có)", + "reason": "Lý do" +} +``` + +### 💻 Hướng dẫn xử lý trên FE Dashboard: +1. **Cập nhật bản đồ**: Chuyển icon vụ việc về màu/trạng thái `Verified` (Chờ điều phối). +2. **Cập nhật danh sách**: Đưa vụ việc vào lại tab "Chờ điều phối". +3. **Thông báo**: Hiển thị Toast thông báo nhiệm vụ đã được giải phóng. + +--- + +## 5) Push Notification (FCM) + +Ngoài SignalR, backend cũng gửi Push Notification để đảm bảo các bên nhận được tin nhắn kể cả khi app đang chạy ngầm. + +### 🔔 Cho Cứu hộ viên (Rescuer) +- **Type**: `SNAKE_RESCUE_MISSION_ABORTED_BY_OPERATOR` +- **Title**: "Nhiệm vụ đã bị hủy bởi điều phối viên" +- **Body**: "Nhiệm vụ của bạn đã bị điều phối viên hủy. Lý do: [reason]" + +### 🔔 Cho Thành viên (Member) +- **Type**: `SNAKE_RESCUE_MISSION_ABORTED` +- **Title**: "Thay đổi cứu hộ viên" +- **Body**: "Điều phối viên đã thay đổi cứu hộ viên cho yêu cầu của bạn để hỗ trợ nhanh hơn." + +--- + +## 6) Checklist Kiểm thử + +1. **Phân quyền**: User thường không thể gọi endpoint này. +2. **Trạng thái**: Chỉ có thể Abort khi Mission là `Preparing` hoặc `EnRoute`. Nếu đã `Arrived` sẽ báo lỗi 400. +3. **Sở hữu**: Chỉ Operator đang trực tiếp xử lý ca đó (HandlingOperator) mới có quyền Abort ca đó (trừ Admin). +4. **Data Integrity**: Kiểm tra Rescuer cũ đã được rảnh tay (`IsAvailable = true`) và Incident đã mất `AssignedRescuerId`. diff --git a/src/apis/incident.api.ts b/src/apis/incident.api.ts index 0b9211e..2a2e53e 100644 --- a/src/apis/incident.api.ts +++ b/src/apis/incident.api.ts @@ -88,6 +88,14 @@ export const incidentApi = { } }, + /** + * Operator forcefully aborts an active rescue mission (Preparing or EnRoute). + * PATCH /api/rescue-missions/{missionId}/operator-abort + * Backend sẽ: set Mission -> MissionAborted, Incident -> Verified, clear AssignedRescuerId. + */ + operatorAbortMission: (missionId: string, payload: { cancellationReason: string }) => + api.patch(`/rescue-missions/${missionId}/operator-abort`, payload), + handoverToHospital: ( incidentId: string, payload: { diff --git a/src/components/operator/IncidentDetailModal.tsx b/src/components/operator/IncidentDetailModal.tsx index b349240..3de260e 100644 --- a/src/components/operator/IncidentDetailModal.tsx +++ b/src/components/operator/IncidentDetailModal.tsx @@ -8,6 +8,7 @@ import { useToast } from '@/components/ToastProvider'; import { PrimaryVenomType, SnakebiteIncidentStatus } from '@/types/snakebite-incident.type'; import DispatchRescuerModal from './DispatchRescuerModal'; import HospitalHandoverModal from './HospitalHandoverModal'; +import IncidentMissionAbortModal from './IncidentMissionAbortModal'; export interface IncidentDetailModalProps { incident: DetailSnakebiteIncidentResponse | null; @@ -38,6 +39,7 @@ export default function IncidentDetailModal({ onRefresh, }: IncidentDetailModalProps) { const [isDispatchModalOpen, setIsDispatchModalOpen] = useState(false); + const [isAbortModalOpen, setIsAbortModalOpen] = useState(false); const [isHospitalHandoverOpen, setIsHospitalHandoverOpen] = useState(false); const [isActionLoading, setIsActionLoading] = useState(false); const [dispatchRequests, setDispatchRequests] = useState([]); @@ -184,6 +186,21 @@ export default function IncidentDetailModal({ } }; + const handleAbortMission = async (reason: string) => { + if (!incident?.activeMission?.id) { + return; + } + try { + await incidentApi.operatorAbortMission(incident.activeMission.id, { cancellationReason: reason }); + showToast('Đã hủy nhiệm vụ thành công. Incident đã về trạng thái Chờ điều phối.', { type: 'success' }); + onRefresh?.(); + } catch (err) { + console.error('Failed to abort mission', err); + showToast('Hủy nhiệm vụ thất bại. Vui lòng thử lại.', { type: 'error' }); + throw err; + } + }; + const renderMedia = () => { if (!incident?.media || incident.media.length === 0) { return

Chưa có ảnh được cung cấp.

; @@ -566,6 +583,15 @@ export default function IncidentDetailModal({ đ )} + {(incident.activeMission.status === 'Preparing' || incident.activeMission.status === 'EnRoute') && ( + + )} @@ -881,6 +907,14 @@ export default function IncidentDetailModal({ }} /> + {/* Mission Abort Modal */} + setIsAbortModalOpen(false)} + onConfirm={handleAbortMission} + /> + {/* Hospital Handover Modal */} {incident && ( void; + onConfirm: (reason: string) => Promise; +} + +export default function IncidentMissionAbortModal({ + isOpen, + isLoading, + onClose, + onConfirm, +}: IncidentMissionAbortModalProps) { + const [selectedValue, setSelectedValue] = useState(''); + const [customReason, setCustomReason] = useState(''); + const [isSubmitting, setIsSubmitting] = useState(false); + const [error, setError] = useState(null); + + // Close on Escape key + useEffect(() => { + if (!isOpen) { + return; + } + const handler = (e: KeyboardEvent) => { + if (e.key === 'Escape') { + onClose(); + } + }; + window.addEventListener('keydown', handler); + return () => { + window.removeEventListener('keydown', handler); + }; + }, [isOpen, onClose]); + + if (!isOpen) { + return null; + } + + const isOther = selectedValue === 'other'; + const finalReason = isOther ? customReason.trim() : selectedValue; + const canSubmit = !isSubmitting && !isLoading && selectedValue !== '' && (!isOther || customReason.trim().length > 0); + + const handleSubmit = async () => { + if (!canSubmit) { + return; + } + setError(null); + setIsSubmitting(true); + try { + await onConfirm(finalReason); + onClose(); + } catch (err) { + setError(err instanceof Error ? err.message : 'Có lỗi xảy ra. Vui lòng thử lại.'); + } finally { + setIsSubmitting(false); + } + }; + + return ( +
+
+ {/* Header */} +
+
+
+ +
+
+

+ Hủy nhiệm vụ cứu hộ +

+

+ Incident sẽ về trạng thái Chờ điều phối +

+
+
+ +
+ + {/* Body */} +
+
+ ⚠️ Chỉ hủy khi Rescuer không phản hồi hoặc có sự cố khách quan. Hành động này không thể hoàn tác. +
+ + {/* Reason options */} +
+

+ Lý do hủy nhiệm vụ +

+ {ABORT_REASONS.map(reason => ( + + ))} +
+ + {/* Custom reason textarea */} + {isOther && ( +
+ +