Update: 2026-08-07 05:03:39
This commit is contained in:
@@ -0,0 +1,76 @@
|
||||
import 'dart:convert';
|
||||
import 'package:get/get.dart';
|
||||
import '../../../constant/links.dart';
|
||||
import '../../../views/widgets/mycircular.dart';
|
||||
import '../../functions/crud.dart';
|
||||
|
||||
/// قائمة الشكاوى لموظف خدمة العملاء.
|
||||
///
|
||||
/// الترتيب: غير المحلولة أولاً ثم الأحدث. الموظف يحتاج ما لم يُغلق بعد،
|
||||
/// لا أحدث ما وصل.
|
||||
class ComplaintListController extends GetxController {
|
||||
final CRUD _crud = CRUD();
|
||||
|
||||
final complaints = <Map<String, dynamic>>[].obs;
|
||||
final isLoading = false.obs;
|
||||
|
||||
/// فلتر الحالة. فارغ = الكل.
|
||||
final statusFilter = ''.obs;
|
||||
|
||||
List<Map<String, dynamic>> get visible {
|
||||
final f = statusFilter.value;
|
||||
if (f.isEmpty) return complaints;
|
||||
return complaints.where((c) => c['statusComplaint'] == f).toList();
|
||||
}
|
||||
|
||||
/// شكاوى مفتوحة مضى عليها أكثر من أسبوع — الرقم الذي يجب ألا يكبر.
|
||||
int get staleCount {
|
||||
final weekAgo = DateTime.now().subtract(const Duration(days: 7));
|
||||
return complaints.where((c) {
|
||||
if (c['statusComplaint'] == 'Resolved') return false;
|
||||
final d = DateTime.tryParse('${c['date_filed']}');
|
||||
return d != null && d.isBefore(weekAgo);
|
||||
}).length;
|
||||
}
|
||||
|
||||
@override
|
||||
void onInit() {
|
||||
super.onInit();
|
||||
fetch();
|
||||
}
|
||||
|
||||
Future<void> fetch() async {
|
||||
isLoading.value = true;
|
||||
try {
|
||||
final res = await _crud.get(link: AppLink.getComplaintAllData);
|
||||
if (res == null || res == 'failure' || res == 'token_expired') {
|
||||
complaints.clear();
|
||||
return;
|
||||
}
|
||||
|
||||
final decoded = res is String ? jsonDecode(res) : res;
|
||||
if (decoded is! Map || decoded['status'] != 'success') {
|
||||
complaints.clear();
|
||||
return;
|
||||
}
|
||||
|
||||
final list = (decoded['message'] as List?) ?? [];
|
||||
final mapped =
|
||||
list.map((e) => Map<String, dynamic>.from(e as Map)).toList();
|
||||
|
||||
// غير المحلول أولاً، ثم الأحدث داخل كل مجموعة.
|
||||
mapped.sort((a, b) {
|
||||
final ar = a['statusComplaint'] == 'Resolved' ? 1 : 0;
|
||||
final br = b['statusComplaint'] == 'Resolved' ? 1 : 0;
|
||||
if (ar != br) return ar - br;
|
||||
return '${b['date_filed']}'.compareTo('${a['date_filed']}');
|
||||
});
|
||||
|
||||
complaints.assignAll(mapped);
|
||||
} catch (e) {
|
||||
mySnackbarError('فشل جلب الشكاوى');
|
||||
} finally {
|
||||
isLoading.value = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
+222
@@ -0,0 +1,222 @@
|
||||
import 'dart:convert';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:get/get.dart';
|
||||
import '../../../constant/links.dart';
|
||||
import '../../../views/widgets/mycircular.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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,11 +2,158 @@ import 'package:flutter/material.dart';
|
||||
import 'package:get/get.dart';
|
||||
import 'package:siro_service/views/widgets/my_scafold.dart';
|
||||
|
||||
import '../complaints/complaint_list_controller.dart';
|
||||
import '../../../views/home/complaints/complaint_resolve_page.dart';
|
||||
|
||||
/// شاشة الشكاوى لموظف خدمة العملاء.
|
||||
///
|
||||
/// كانت هيكلاً فارغاً (`body: []`). الآن: قائمة مرتّبة بغير المحلول أولاً،
|
||||
/// وتنبيه بالمتأخر، وفتح أداة التسوية بضغطة.
|
||||
class Complaint extends StatelessWidget {
|
||||
const Complaint({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return MyScaffold(title: "View complaint".tr, isleading: true, body: []);
|
||||
final c = Get.put(ComplaintListController());
|
||||
final cs = Theme.of(context).colorScheme;
|
||||
|
||||
return MyScaffold(
|
||||
title: "View complaint".tr,
|
||||
isleading: true,
|
||||
body: [
|
||||
Obx(() {
|
||||
if (c.isLoading.value && c.complaints.isEmpty) {
|
||||
return const Padding(
|
||||
padding: EdgeInsets.only(top: 60),
|
||||
child: Center(child: CircularProgressIndicator()),
|
||||
);
|
||||
}
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// المتأخر أولاً وبصرياً: رقم يجب ألا يكبر، فإخفاؤه في قائمة
|
||||
// طويلة يعني أن أحداً لن يلاحظه.
|
||||
if (c.staleCount > 0)
|
||||
Container(
|
||||
margin: const EdgeInsets.only(bottom: 12),
|
||||
padding: const EdgeInsets.all(14),
|
||||
decoration: BoxDecoration(
|
||||
color: cs.error.withOpacity(0.08),
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
border: Border.all(color: cs.error.withOpacity(0.3)),
|
||||
),
|
||||
child: Row(children: [
|
||||
Icon(Icons.warning_amber_rounded, color: cs.error, size: 20),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'${c.staleCount} شكوى مفتوحة منذ أكثر من أسبوع',
|
||||
style: TextStyle(
|
||||
color: cs.error,
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.bold),
|
||||
),
|
||||
),
|
||||
]),
|
||||
),
|
||||
|
||||
_Filters(c: c),
|
||||
const SizedBox(height: 8),
|
||||
|
||||
if (c.visible.isEmpty)
|
||||
const Padding(
|
||||
padding: EdgeInsets.only(top: 60),
|
||||
child: Center(child: Text('لا شكاوى')),
|
||||
)
|
||||
else
|
||||
...c.visible.map((item) => _ComplaintTile(item: item, c: c)),
|
||||
],
|
||||
);
|
||||
}),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _Filters extends StatelessWidget {
|
||||
const _Filters({required this.c});
|
||||
final ComplaintListController c;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
const options = <String, String>{
|
||||
'': 'الكل',
|
||||
'Open': 'مفتوحة',
|
||||
'In Progress': 'قيد المعالجة',
|
||||
'Resolved': 'محلولة',
|
||||
};
|
||||
|
||||
return Obx(() => Wrap(
|
||||
spacing: 8,
|
||||
children: options.entries
|
||||
.map((e) => ChoiceChip(
|
||||
label: Text(e.value, style: const TextStyle(fontSize: 12)),
|
||||
selected: c.statusFilter.value == e.key,
|
||||
onSelected: (_) => c.statusFilter.value = e.key,
|
||||
))
|
||||
.toList(),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
class _ComplaintTile extends StatelessWidget {
|
||||
const _ComplaintTile({required this.item, required this.c});
|
||||
final Map<String, dynamic> item;
|
||||
final ComplaintListController c;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final cs = Theme.of(context).colorScheme;
|
||||
final status = item['statusComplaint']?.toString() ?? 'Open';
|
||||
|
||||
final color = status == 'Resolved'
|
||||
? Colors.green
|
||||
: status == 'In Progress'
|
||||
? Colors.orange
|
||||
: cs.error;
|
||||
|
||||
return Card(
|
||||
margin: const EdgeInsets.only(bottom: 10),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)),
|
||||
child: ListTile(
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 14, vertical: 6),
|
||||
leading: Container(
|
||||
width: 8,
|
||||
height: 44,
|
||||
decoration: BoxDecoration(
|
||||
color: color,
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
),
|
||||
title: Text(
|
||||
item['description']?.toString().trim().isNotEmpty == true
|
||||
? item['description'].toString()
|
||||
: 'بدون وصف',
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w600),
|
||||
),
|
||||
subtitle: Padding(
|
||||
padding: const EdgeInsets.only(top: 4),
|
||||
child: Text(
|
||||
'رحلة ${item['ride_id']} · ${item['complaint_type'] ?? 'عام'}'
|
||||
' · ${item['date_filed'] ?? ''}',
|
||||
style: TextStyle(fontSize: 11, color: cs.onSurfaceVariant),
|
||||
),
|
||||
),
|
||||
trailing: Icon(Icons.chevron_left, color: cs.onSurfaceVariant),
|
||||
onTap: () async {
|
||||
final changed = await Get.to(
|
||||
() => ComplaintResolvePage(complaint: item));
|
||||
if (changed == true) c.fetch();
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user