223 lines
8.5 KiB
Dart
223 lines
8.5 KiB
Dart
import 'dart:convert';
|
|
import 'package:flutter/material.dart';
|
|
import 'package:get/get.dart';
|
|
import '../../constant/links.dart';
|
|
import '../../views/widgets/snackbar.dart';
|
|
import '../functions/crud.dart';
|
|
|
|
/// كنترولر شاشة تسوية الشكوى.
|
|
///
|
|
/// يفصل بين شيئين متعمّداً:
|
|
/// • **التحليل المحفوظ** — أنتجه Gemini وقت تقديم الشكوى وحُفظ في
|
|
/// الجدول (cs_solutions / passenger_report / driver_report). يُعرض
|
|
/// مجاناً بلا أي نداء خارجي.
|
|
/// • **توصية المبلغ** — نداء جديد بضغطة زر. مدفوع، فلا يُطلق تلقائياً
|
|
/// عند فتح كل شكوى.
|
|
class ComplaintResolveController extends GetxController {
|
|
ComplaintResolveController(this.complaint);
|
|
|
|
/// صف الشكوى كما جاء من getComplaintAllData.php
|
|
final Map<String, dynamic> complaint;
|
|
|
|
final CRUD _crud = CRUD();
|
|
|
|
// ── حالة الشاشة ──
|
|
final isSaving = false.obs;
|
|
final isSuggesting = false.obs;
|
|
|
|
// ── حقول التسوية ──
|
|
final status = 'In Progress'.obs;
|
|
final reasonCode = RxnString();
|
|
final faultOn = RxnString();
|
|
final resolutionCtrl = TextEditingController();
|
|
|
|
// ── التعويض ──
|
|
final compEnabled = false.obs;
|
|
final compKind = 'goodwill'.obs;
|
|
final compBeneficiary = 'passenger'.obs;
|
|
final compAmountCtrl = TextEditingController();
|
|
final compNoteCtrl = TextEditingController();
|
|
|
|
/// السقف الصلب بعملة الدولة — يصل من نداء التوصية. صفر يعني "غير معروف
|
|
/// بعد"، فلا نفرض حداً في الواجهة ونترك الخادم يرفض إن تجاوز.
|
|
final hardCap = 0.0.obs;
|
|
final currency = ''.obs;
|
|
|
|
/// توصية الذكاء الاصطناعي بعد الضغط على الزر. فارغة = لم تُطلب بعد.
|
|
final suggestion = Rxn<Map<String, dynamic>>();
|
|
|
|
static const statuses = ['Open', 'In Progress', 'Resolved'];
|
|
|
|
/// نفس القائمة المغلقة في serviceapp/resolve_complaint.php. أي تعديل
|
|
/// هناك يجب أن ينعكس هنا، وإلا رُفض الطلب بـ Invalid reason_code.
|
|
static const reasons = <String, String>{
|
|
'driver_behavior': 'سلوك السائق',
|
|
'passenger_behavior': 'سلوك الراكب',
|
|
'overcharge': 'سعر أعلى من المتوقع',
|
|
'route_issue': 'مسار خاطئ أو أطول',
|
|
'no_show': 'عدم حضور',
|
|
'vehicle_condition': 'حالة المركبة',
|
|
'app_issue': 'عطل تقني',
|
|
'payment_issue': 'مشكلة دفع',
|
|
'safety': 'سلامة',
|
|
'other': 'أخرى',
|
|
};
|
|
|
|
static const faults = <String, String>{
|
|
'driver': 'السائق',
|
|
'passenger': 'الراكب',
|
|
'company': 'الشركة',
|
|
'unclear': 'غير واضح',
|
|
};
|
|
|
|
// ── التحليل المحفوظ (بلا أي نداء) ──
|
|
String get savedSolutions => _s('cs_solutions');
|
|
String get savedPassengerReport => _s('passenger_report');
|
|
String get savedDriverReport => _s('driver_report');
|
|
String get savedNature => _s('complaint_nature');
|
|
String get savedFault => _s('fault_determination');
|
|
bool get hasSavedAnalysis =>
|
|
savedSolutions.isNotEmpty ||
|
|
savedPassengerReport.isNotEmpty ||
|
|
savedDriverReport.isNotEmpty;
|
|
|
|
String _s(String key) => complaint[key]?.toString().trim() ?? '';
|
|
|
|
@override
|
|
void onInit() {
|
|
super.onInit();
|
|
final current = _s('statusComplaint');
|
|
if (statuses.contains(current)) status.value = current;
|
|
if (reasons.containsKey(_s('reason_code'))) reasonCode.value = _s('reason_code');
|
|
resolutionCtrl.text = _s('resolution');
|
|
}
|
|
|
|
@override
|
|
void onClose() {
|
|
resolutionCtrl.dispose();
|
|
compAmountCtrl.dispose();
|
|
compNoteCtrl.dispose();
|
|
super.onClose();
|
|
}
|
|
|
|
/// يطلب توصية المبلغ. لا يملأ الحقول تلقائياً — يعرضها ليقرّر الموظف،
|
|
/// فالتعبئة الصامتة تجعل الضغط على "حفظ" موافقةً ضمنية على رقم لم يُقرأ.
|
|
Future<void> requestSuggestion() async {
|
|
if (isSuggesting.value) return;
|
|
isSuggesting.value = true;
|
|
try {
|
|
final res = await _crud.post(
|
|
link: AppLink.complaintAiSuggest,
|
|
payload: {'complaint_id': complaint['id'].toString()},
|
|
);
|
|
|
|
final decoded = res is String ? jsonDecode(res) : res;
|
|
final data = decoded is Map ? (decoded['message'] ?? decoded['data']) : null;
|
|
|
|
if (data is! Map || data['available'] != true) {
|
|
mySnackbarError('تعذّر الحصول على توصية الآن');
|
|
return;
|
|
}
|
|
|
|
suggestion.value = Map<String, dynamic>.from(data);
|
|
|
|
final comp = data['compensation'];
|
|
if (comp is Map) {
|
|
hardCap.value = double.tryParse('${comp['hard_cap']}') ?? 0;
|
|
currency.value = comp['currency']?.toString() ?? '';
|
|
}
|
|
} catch (e) {
|
|
mySnackbarError('تعذّر الاتصال بخدمة التوصية');
|
|
} finally {
|
|
isSuggesting.value = false;
|
|
}
|
|
}
|
|
|
|
/// ينسخ توصية الذكاء الاصطناعي إلى الحقول — بفعل صريح من الموظف.
|
|
void applySuggestion() {
|
|
final s = suggestion.value;
|
|
if (s == null) return;
|
|
|
|
if (reasons.containsKey(s['reason_code'])) reasonCode.value = s['reason_code'];
|
|
if (faults.containsKey(s['fault_determination'])) {
|
|
faultOn.value = s['fault_determination'];
|
|
}
|
|
if (statuses.contains(s['suggested_status'])) status.value = s['suggested_status'];
|
|
|
|
final reply = s['customer_reply']?.toString() ?? '';
|
|
if (reply.isNotEmpty) resolutionCtrl.text = reply;
|
|
|
|
final comp = s['compensation'];
|
|
if (comp is Map) {
|
|
final amount = double.tryParse('${comp['amount']}') ?? 0;
|
|
if (amount > 0) {
|
|
compEnabled.value = true;
|
|
compAmountCtrl.text = amount.toString();
|
|
compKind.value = comp['kind']?.toString() ?? 'goodwill';
|
|
compBeneficiary.value = comp['beneficiary']?.toString() ?? 'passenger';
|
|
}
|
|
}
|
|
update();
|
|
}
|
|
|
|
/// يحفظ التسوية. يرجع true عند النجاح ليغلق المُنادي الشاشة.
|
|
Future<bool> save() async {
|
|
if (isSaving.value) return false;
|
|
|
|
final payload = <String, String>{
|
|
'complaint_id': complaint['id'].toString(),
|
|
'status': status.value,
|
|
if (reasonCode.value != null) 'reason_code': reasonCode.value!,
|
|
if (faultOn.value != null) 'fault_determination': faultOn.value!,
|
|
'resolution': resolutionCtrl.text.trim(),
|
|
};
|
|
|
|
if (compEnabled.value) {
|
|
final amount = double.tryParse(compAmountCtrl.text.trim()) ?? 0;
|
|
if (amount <= 0) {
|
|
mySnackbarError('أدخل مبلغ تعويض صحيحاً');
|
|
return false;
|
|
}
|
|
// فحص محلي للراحة فقط — الخادم هو من يفرض السقف فعلاً.
|
|
if (hardCap.value > 0 && amount > hardCap.value) {
|
|
mySnackbarError('المبلغ يتجاوز الحد ${hardCap.value} ${currency.value}');
|
|
return false;
|
|
}
|
|
payload['compensation_amount'] = amount.toString();
|
|
payload['compensation_kind'] = compKind.value;
|
|
payload['compensation_beneficiary'] = compBeneficiary.value;
|
|
if (compNoteCtrl.text.trim().isNotEmpty) {
|
|
payload['compensation_note'] = compNoteCtrl.text.trim();
|
|
}
|
|
}
|
|
|
|
isSaving.value = true;
|
|
try {
|
|
final res = await _crud.post(link: AppLink.resolveComplaint, payload: payload);
|
|
final decoded = res is String ? jsonDecode(res) : res;
|
|
|
|
if (decoded is Map && decoded['status'] == 'success') {
|
|
// التحويل قد يفشل بينما تُحفظ القضية. نُظهر ذلك صراحةً بدل رسالة
|
|
// نجاح عامة تُخفي أن العميل لم يستلم ما وُعد به.
|
|
final data = decoded['message'] ?? decoded['data'];
|
|
final comp = (data is Map) ? data['compensation'] : null;
|
|
if (comp is Map && comp['status'] == 'failed') {
|
|
mySnackbarError('حُفظت القضية، لكن التعويض لم يصل — يحتاج تسوية يدوية');
|
|
} else {
|
|
mySnackbarSuccess('تم حفظ التسوية');
|
|
}
|
|
return true;
|
|
}
|
|
|
|
final msg = (decoded is Map ? decoded['message'] : null)?.toString();
|
|
mySnackbarError(msg?.isNotEmpty == true ? msg! : 'فشل حفظ التسوية');
|
|
return false;
|
|
} catch (e) {
|
|
mySnackbarError('تعذّر الاتصال بالخادم');
|
|
return false;
|
|
} finally {
|
|
isSaving.value = false;
|
|
}
|
|
}
|
|
}
|