diff --git a/backend/connect.php b/backend/connect.php index 909601b0..b7ef54a8 100644 --- a/backend/connect.php +++ b/backend/connect.php @@ -53,23 +53,28 @@ $role = $decoded->role ?? 'passenger'; // ‏update_complaint — أي أن أي حامل JWT صالح (راكب عادي) يقرأ بيانات // ‏السائقين والركّاب ويغلق الشكاوى. تحققنا أن siro_rider و siro_driver لا // ‏يستدعيان أي نقطة تحت serviceapp/، فالإغلاق لا يكسر تطبيقات الجوال. +// +// ⚠️ ‏لكل مجلد أدواره: تسجيل دخول تطبيق الخدمة (Admin/jwtService.php:51) +// ‏يصدر الدور 'service' لا 'admin'. فرضُ admin على serviceapp/ كان +// ‏سيمنع كل موظفي خدمة العملاء من كل نقاطهم. $gatedDirs = array_filter([ - realpath(__DIR__ . '/Admin'), - realpath(__DIR__ . '/serviceapp'), -]); -$script = realpath($_SERVER['SCRIPT_FILENAME'] ?? ''); + realpath(__DIR__ . '/Admin') => ['admin', 'super_admin'], + realpath(__DIR__ . '/serviceapp') => ['service', 'admin', 'super_admin'], +], fn($k) => $k !== false, ARRAY_FILTER_USE_KEY); -$inGatedDir = false; +$script = realpath($_SERVER['SCRIPT_FILENAME'] ?? ''); + +$gateRoles = null; if ($script) { - foreach ($gatedDirs as $dir) { + foreach ($gatedDirs as $dir => $roles) { if (str_starts_with($script, $dir . DIRECTORY_SEPARATOR)) { - $inGatedDir = true; + $gateRoles = $roles; break; } } } -if ($inGatedDir) { +if ($gateRoles !== null) { // نقاط تحت Admin/ تستدعيها تطبيقات الجوال بأدوار غير إدارية. // تم التحقق من مواضع الاستدعاء الفعلية في siro_rider و siro_driver: // errorApp.php → التطبيقان يبلّغان عن الأخطاء @@ -81,8 +86,7 @@ if ($inGatedDir) { ]; if (!in_array(basename($script), $adminGateExempt, true) - && $role !== 'admin' - && $role !== 'super_admin' + && !in_array($role, $gateRoles, true) ) { securityLog('Admin gate blocked non-admin request', [ 'script' => basename($script), diff --git a/backend/ride/feedBack/add_solve_all.php b/backend/ride/feedBack/add_solve_all.php index a410f7d2..9ec1fb48 100644 --- a/backend/ride/feedBack/add_solve_all.php +++ b/backend/ride/feedBack/add_solve_all.php @@ -55,88 +55,9 @@ $driverId = $ride['driver_id']; // --- دوال مساعدة لجلب البيانات (تم افتراض أن الدوال تصل إلى $con و $encryptionHelper عبر النطاق global أو تمريرها كـ arguments) --- -/** - * جلب بيانات ومعلومات تقييم السائق - */ -function getDriverFullProfile($con, $encryptionHelper, $driverId) { - $profile = ['info' => null, 'ratings' => null, 'comments' => []]; - - // جلب معلومات السائق الأساسية - $stmt = $con->prepare("SELECT id, first_name, last_name, created_at FROM driver WHERE id = ?"); - $stmt->execute([$driverId]); - $driverInfo = $stmt->fetch(PDO::FETCH_ASSOC); - - // فك تشفير البيانات الحساسة - if ($driverInfo) { - // التحقق من وجود ودوال فك التشفير قبل الاستخدام - if (isset($encryptionHelper) && method_exists($encryptionHelper, 'decryptData')) { - $decryptedFirstName = $encryptionHelper->decryptData($driverInfo['first_name']); - $decryptedLastName = $encryptionHelper->decryptData($driverInfo['last_name']); - $driverInfo['full_name'] = trim($decryptedFirstName . ' ' . $decryptedLastName); - } else { - $driverInfo['full_name'] = 'Decryption Failed'; - } - - unset($driverInfo['first_name'], $driverInfo['last_name']); // إزالة الحقول المشفرة - $profile['info'] = $driverInfo; - } - - // جلب ملخص التقييمات والتعليقات - $stmt = $con->prepare("SELECT AVG(rating) as avg_rating, COUNT(id) as total_ratings FROM ratingDriver WHERE driver_id = ?"); - $stmt->execute([$driverId]); - $profile['ratings'] = $stmt->fetch(PDO::FETCH_ASSOC); - - $stmt = $con->prepare("SELECT comment FROM ratingDriver WHERE driver_id = ? AND comment IS NOT NULL AND comment != '' ORDER BY created_at DESC LIMIT 5"); - $stmt->execute([$driverId]); - $profile['comments'] = $stmt->fetchAll(PDO::FETCH_COLUMN); - - return $profile; -} - -/** - * جلب بيانات ومعلومات تقييم الراكب - */ -function getPassengerFullProfile($con, $encryptionHelper, $passengerId) { - $profile = ['info' => null, 'ratings' => null, 'comments' => []]; - - $stmt = $con->prepare("SELECT id, first_name, last_name, created_at FROM passengers WHERE id = ?"); - $stmt->execute([$passengerId]); - $passengerInfo = $stmt->fetch(PDO::FETCH_ASSOC); - - // فك تشفير البيانات الحساسة - if ($passengerInfo) { - if (isset($encryptionHelper) && method_exists($encryptionHelper, 'decryptData')) { - $decryptedFirstName = $encryptionHelper->decryptData($passengerInfo['first_name']); - $decryptedLastName = $encryptionHelper->decryptData($passengerInfo['last_name']); - $passengerInfo['full_name'] = trim($decryptedFirstName . ' ' . $decryptedLastName); - } else { - $passengerInfo['full_name'] = 'Decryption Failed'; - } - - unset($passengerInfo['first_name'], $passengerInfo['last_name']); - $profile['info'] = $passengerInfo; - } - - $stmt = $con->prepare("SELECT AVG(rating) as avg_rating, COUNT(id) as total_ratings FROM ratingPassenger WHERE passenger_id = ?"); - $stmt->execute([$passengerId]); - $profile['ratings'] = $stmt->fetch(PDO::FETCH_ASSOC); - - $stmt = $con->prepare("SELECT comment FROM ratingPassenger WHERE passenger_id = ? AND comment IS NOT NULL AND comment != '' ORDER BY created_at DESC LIMIT 5"); - $stmt->execute([$passengerId]); - $profile['comments'] = $stmt->fetchAll(PDO::FETCH_COLUMN); - - return $profile; -} - -/** - * جلب بيانات سلوك السائق في الرحلة المحددة - */ -function getDriverBehavior($con, $rideId, $driverId) { - $stmt = $con->prepare("SELECT max_speed, avg_speed, hard_brakes, behavior_score FROM driver_behavior WHERE trip_id = ? AND driver_id = ?"); - $stmt->execute([$rideId, $driverId]); - return $stmt->fetch(PDO::FETCH_ASSOC) ?: null; -} - +// ‏الدوال الثلاث انتقلت إلى complaint_context.php ليشاركها مستشار +// ‏خدمة العملاء (serviceapp/complaint_ai_suggest.php) بلا تكرار. +require_once __DIR__ . '/complaint_context.php'; // استدعاء الدوال لجلب البيانات $passengerProfile = getPassengerFullProfile($con, $encryptionHelper, $passengerId); $driverProfile = getDriverFullProfile($con, $encryptionHelper, $driverId); diff --git a/backend/ride/feedBack/complaint_context.php b/backend/ride/feedBack/complaint_context.php new file mode 100644 index 00000000..4b17e255 --- /dev/null +++ b/backend/ride/feedBack/complaint_context.php @@ -0,0 +1,95 @@ + null, 'ratings' => null, 'comments' => []]; + + // جلب معلومات السائق الأساسية + $stmt = $con->prepare("SELECT id, first_name, last_name, created_at FROM driver WHERE id = ?"); + $stmt->execute([$driverId]); + $driverInfo = $stmt->fetch(PDO::FETCH_ASSOC); + + // فك تشفير البيانات الحساسة + if ($driverInfo) { + // التحقق من وجود ودوال فك التشفير قبل الاستخدام + if (isset($encryptionHelper) && method_exists($encryptionHelper, 'decryptData')) { + $decryptedFirstName = $encryptionHelper->decryptData($driverInfo['first_name']); + $decryptedLastName = $encryptionHelper->decryptData($driverInfo['last_name']); + $driverInfo['full_name'] = trim($decryptedFirstName . ' ' . $decryptedLastName); + } else { + $driverInfo['full_name'] = 'Decryption Failed'; + } + + unset($driverInfo['first_name'], $driverInfo['last_name']); // إزالة الحقول المشفرة + $profile['info'] = $driverInfo; + } + + // جلب ملخص التقييمات والتعليقات + $stmt = $con->prepare("SELECT AVG(rating) as avg_rating, COUNT(id) as total_ratings FROM ratingDriver WHERE driver_id = ?"); + $stmt->execute([$driverId]); + $profile['ratings'] = $stmt->fetch(PDO::FETCH_ASSOC); + + $stmt = $con->prepare("SELECT comment FROM ratingDriver WHERE driver_id = ? AND comment IS NOT NULL AND comment != '' ORDER BY created_at DESC LIMIT 5"); + $stmt->execute([$driverId]); + $profile['comments'] = $stmt->fetchAll(PDO::FETCH_COLUMN); + + return $profile; +} + +/** + * جلب بيانات ومعلومات تقييم الراكب + */ +function getPassengerFullProfile($con, $encryptionHelper, $passengerId) { + $profile = ['info' => null, 'ratings' => null, 'comments' => []]; + + $stmt = $con->prepare("SELECT id, first_name, last_name, created_at FROM passengers WHERE id = ?"); + $stmt->execute([$passengerId]); + $passengerInfo = $stmt->fetch(PDO::FETCH_ASSOC); + + // فك تشفير البيانات الحساسة + if ($passengerInfo) { + if (isset($encryptionHelper) && method_exists($encryptionHelper, 'decryptData')) { + $decryptedFirstName = $encryptionHelper->decryptData($passengerInfo['first_name']); + $decryptedLastName = $encryptionHelper->decryptData($passengerInfo['last_name']); + $passengerInfo['full_name'] = trim($decryptedFirstName . ' ' . $decryptedLastName); + } else { + $passengerInfo['full_name'] = 'Decryption Failed'; + } + + unset($passengerInfo['first_name'], $passengerInfo['last_name']); + $profile['info'] = $passengerInfo; + } + + $stmt = $con->prepare("SELECT AVG(rating) as avg_rating, COUNT(id) as total_ratings FROM ratingPassenger WHERE passenger_id = ?"); + $stmt->execute([$passengerId]); + $profile['ratings'] = $stmt->fetch(PDO::FETCH_ASSOC); + + $stmt = $con->prepare("SELECT comment FROM ratingPassenger WHERE passenger_id = ? AND comment IS NOT NULL AND comment != '' ORDER BY created_at DESC LIMIT 5"); + $stmt->execute([$passengerId]); + $profile['comments'] = $stmt->fetchAll(PDO::FETCH_COLUMN); + + return $profile; +} + +/** + * جلب بيانات سلوك السائق في الرحلة المحددة + */ +function getDriverBehavior($con, $rideId, $driverId) { + $stmt = $con->prepare("SELECT max_speed, avg_speed, hard_brakes, behavior_score FROM driver_behavior WHERE trip_id = ? AND driver_id = ?"); + $stmt->execute([$rideId, $driverId]); + return $stmt->fetch(PDO::FETCH_ASSOC) ?: null; +} + diff --git a/backend/ride/pricing/pricing_helper.php b/backend/ride/pricing/pricing_helper.php index 16551c52..0cb1c6fb 100644 --- a/backend/ride/pricing/pricing_helper.php +++ b/backend/ride/pricing/pricing_helper.php @@ -166,7 +166,6 @@ function estimateDriverEarningsAdvantage( 'competitor_name' => $data['competitor_name'], ]; } -?> /** * ‏صف التسعير الصحيح لدولة هذه الرحلة. @@ -230,3 +229,35 @@ function getKazanForRide(PDO $con, $rideId, string $fallbackCountry = 'Syria', $ return []; } } + +/** + * ‏السقف الصلب الأعلى لأي حركة مالية واحدة، بعملة الدولة. + * + * ⚠️ ‏رقم مجرّد لا معنى له: كان الكود يستخدم 50000 في ثلاثة مواضع، وهو + * ‏سقف بلا أثر على الليرة السورية بعد حذف صفرين منها (50000 القديمة = + * ‏500 اليوم)، وكارثي على الدينار الأردني (50000 دينار!). + * + * ‏هذا ليس سعراً ولا سياسة — هو حزام أمان أخير ضد خطأ مطبعي (صفر زائد) + * ‏أو حساب موظف مخترَق. السياسة الحقيقية تُضبط في أعمدة kazan. + */ +function getMoneyHardCap(string $currency): float +{ + // ‏سقوف حددها المالك: أقصى تعويض واحد ≈ دينار أردني واحد وما يعادله. + // ‏رقم صغير عمداً — التعويض استرضاء لا تسوية نزاع، والحالات الكبيرة + // ‏تُرفع للإدارة لا تُصرف من شاشة موظف. + $caps = [ + 'JOD' => 1.0, + 'EGP' => 50.0, + 'SYP' => 100.0, // ‏بعد حذف الصفرين + 'IQD' => 2000.0, + 'USD' => 1.5, + 'TRY' => 50.0, + 'SAR' => 5.0, + 'AED' => 5.0, + 'LBP' => 130000.0, + ]; + + // ‏عملة غير معروفة: نأخذ أدنى سقف في القائمة. الرفض الخاطئ يُراجَع، + // ‏والصرف الخاطئ لا يُسترد. + return $caps[strtoupper($currency)] ?? min($caps); +} diff --git a/backend/ride/rides/cancel_fee_helper.php b/backend/ride/rides/cancel_fee_helper.php index 3600f77e..b1659147 100644 --- a/backend/ride/rides/cancel_fee_helper.php +++ b/backend/ride/rides/cancel_fee_helper.php @@ -12,9 +12,6 @@ // • الأسعار ← جدول kazan عبر pricing_helper.php // ============================================================ -// ‏سقف صلب فوق أي قيمة في القاعدة — يمنع خطأً إدارياً من خصم كارثي. -const PASSENGER_CANCEL_ABSOLUTE_CAP = 50000.0; - // ‏حد أقصى للدقائق المحتسبة: سائق قَبِل ونسي الرحلة ساعة لا يتحول // ‏نسيانه إلى فاتورة على الراكب. const PASSENGER_CANCEL_MAX_BILLABLE_MINUTES = 20; @@ -112,7 +109,8 @@ function computePassengerCancelFee( $fee = ($approachKm * getPerKmRate($carType, $kazan)) + ($billableMinutes * getPerMinRate($kazan)); - $fee = round(min($fee, $cap, PASSENGER_CANCEL_ABSOLUTE_CAP), 2); + // ‏سقف صلب بعملة الدولة — حزام أمان أخير ضد خطأ في صف kazan. + $fee = round(min($fee, $cap, getMoneyHardCap($currency)), 2); error_log("[cancel_fee] ride=$rideId رسم إلغاء: {$approachKm}كم" . " + {$billableMinutes}د = $fee $currency (سقف $cap)"); diff --git a/backend/ride/rides/cancel_ride_by_driver.php b/backend/ride/rides/cancel_ride_by_driver.php index e7859659..2ebdcb32 100644 --- a/backend/ride/rides/cancel_ride_by_driver.php +++ b/backend/ride/rides/cancel_ride_by_driver.php @@ -30,10 +30,6 @@ if (filterRequest("penalty_fee") !== null) { . " (ride=$rideId driver=$driverId) — التعويض يُحسب على الخادم"); } -// ‏سقف صلب فوق أي قيمة في قاعدة البيانات — يمنع خطأ إدارياً أو صفاً تالفاً -// ‏من تحويل التعويض إلى خصم كارثي. -const CANCEL_COMPENSATION_ABSOLUTE_CAP = 50000.0; - // ‏حد أقصى لدقائق الانتظار المحتسبة: سائق نسي الرحلة مفتوحة ساعتين لا // ‏يتحول انتظاره إلى فاتورة على الراكب. const CANCEL_MAX_BILLABLE_WAIT_MINUTES = 20; @@ -261,8 +257,11 @@ try { $perMin = getPerMinRate($kazan); $compensation = ($approachKm * $perKm) + ($billableMinutes * $perMin); + // ‏سقف صلب بعملة الدولة لا رقماً مجرّداً: 50000 كانت + // ‏بلا أثر على الليرة بعد حذف صفرين، وكارثية على الدينار. + $currencyCode = (string) ($kazan['currency'] ?? 'SYP'); $compensation = round(min($compensation, $compensationCap, - CANCEL_COMPENSATION_ABSOLUTE_CAP), 2); + getMoneyHardCap($currencyCode)), 2); error_log("[cancel_ride_by_driver] تعويض ride=$rideId:" . " {$approachKm}كم×$perKm + {$billableMinutes}د×$perMin" diff --git a/backend/serviceapp/complaint_ai_suggest.php b/backend/serviceapp/complaint_ai_suggest.php new file mode 100644 index 00000000..46b4b6ee --- /dev/null +++ b/backend/serviceapp/complaint_ai_suggest.php @@ -0,0 +1,223 @@ +prepare("SELECT * FROM complaint WHERE id = ? LIMIT 1"); + $stmt->execute([$complaintId]); + $complaint = $stmt->fetch(PDO::FETCH_ASSOC); + + if (!$complaint) { + jsonError("Complaint not found", 404); + } + + $rideId = $complaint['ride_id'] ?? null; + + // ── سياق الرحلة ───────────────────────────────────────── + // ‏بدونه النموذج يخمّن. مع السعر والمسافة والحالة يصير الاقتراح مبنياً + // ‏على وقائع. + $ride = []; + $currency = 'SYP'; + $hardCap = 0.0; + + if ($rideId) { + $stmtRide = $con->prepare("SELECT * FROM ride WHERE id = ? LIMIT 1"); + $stmtRide->execute([$rideId]); + $ride = $stmtRide->fetch(PDO::FETCH_ASSOC) ?: []; + + $kazan = getKazanForRide($con, $rideId); + $currency = !empty($kazan['currency']) + ? (string) $kazan['currency'] + : getCurrencyByCountry((string) ($kazan['country'] ?? 'Syria')); + } + $hardCap = getMoneyHardCap($currency); + + // ── سوابق الطرفين ─────────────────────────────────────── + // ‏شكوى أولى من راكب مختلفة عن الخامسة. والسائق الذي تكررت شكاواه + // ‏مختلف عمّن يُشتكى عليه أول مرة. + $history = ['passenger_complaints' => 0, 'driver_complaints' => 0]; + try { + if (!empty($complaint['passenger_id'])) { + $st = $con->prepare("SELECT COUNT(*) FROM complaint + WHERE passenger_id = ? AND id <> ?"); + $st->execute([$complaint['passenger_id'], $complaintId]); + $history['passenger_complaints'] = (int) $st->fetchColumn(); + } + if (!empty($complaint['driver_id'])) { + $st = $con->prepare("SELECT COUNT(*) FROM complaint + WHERE driver_id = ? AND id <> ?"); + $st->execute([$complaint['driver_id'], $complaintId]); + $history['driver_complaints'] = (int) $st->fetchColumn(); + } + } catch (PDOException $e) { + error_log("[complaint/ai] تعذّر جلب السوابق: " . $e->getMessage()); + } + + // ── بناء المُوجِّه ────────────────────────────────────── + // ‏لا نمرّر أسماء ولا هواتف ولا بريداً: النموذج خدمة خارجية، وما يخرج + // ‏من الخادم لا يعود. المعرّفات وحدها تكفي للتحليل. + // ‏الملفان الكاملان: تقييم كل طرف، وتعليقات من تعاملوا معه سابقاً. + // ‏هذا ما يجعل النموذج يعرف "من هؤلاء" لا الشكوى وحدها — راكب تكررت + // ‏شكاواه الكيدية مختلف عن راكب أول شكوى له، والسائق كذلك. + $driverProfile = !empty($complaint['driver_id']) + ? getDriverFullProfile($con, $encryptionHelper ?? null, $complaint['driver_id']) + : null; + $passengerProfile = !empty($complaint['passenger_id']) + ? getPassengerFullProfile($con, $encryptionHelper ?? null, $complaint['passenger_id']) + : null; + $driverBehavior = ($rideId && !empty($complaint['driver_id'])) + ? getDriverBehavior($con, $rideId, $complaint['driver_id']) + : null; + + // ‏نزع الأسماء: الملفات تحمل first_name/last_name، والنموذج خدمة + // ‏خارجية. التقييمات والتعليقات تكفي للتحليل بلا هوية. + foreach ([&$driverProfile, &$passengerProfile] as &$pr) { + if (is_array($pr) && isset($pr['info']) && is_array($pr['info'])) { + unset($pr['info']['first_name'], $pr['info']['last_name']); + } + } + unset($pr); + + $context = [ + 'complaint' => [ + 'type' => $complaint['complaint_type'] ?? '', + 'description' => mb_substr((string) ($complaint['description'] ?? ''), 0, 1500), + 'passenger_report' => mb_substr((string) ($complaint['passenger_report'] ?? ''), 0, 800), + 'driver_report' => mb_substr((string) ($complaint['driver_report'] ?? ''), 0, 800), + 'prior_ai_solutions' => mb_substr((string) ($complaint['cs_solutions'] ?? ''), 0, 800), + 'nature' => $complaint['complaint_nature'] ?? null, + 'filed_at' => $complaint['date_filed'] ?? '', + ], + 'ride' => [ + 'status' => $ride['status'] ?? null, + 'price' => $ride['price'] ?? null, + 'distance_km' => $ride['distance'] ?? null, + 'car_type' => $ride['carType'] ?? $ride['car_type'] ?? null, + ], + 'driver_profile' => $driverProfile, + 'passenger_profile' => $passengerProfile, + 'driver_behavior' => $driverBehavior, + 'complaint_counts' => $history, + 'currency' => $currency, + ]; + + // ‏التحويل قبل الـheredoc: استدعاء دالة داخله غير مسموح، و$this + // ‏لا وجود له في سكربت إجرائي (خطأ قاتل لا يلتقطه php -l). + $contextJson = json_encode($context, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT); + + $reasons = implode(', ', [ + 'driver_behavior', 'passenger_behavior', 'overcharge', 'route_issue', + 'no_show', 'vehicle_condition', 'app_issue', 'payment_issue', 'safety', 'other', + ]); + + $prompt = <<callGemini($prompt, $model); + + $text = $raw['candidates'][0]['content']['parts'][0]['text'] ?? null; + + if (!$text) { + // ‏غياب الاقتراح لا يعطّل الموظف — الشاشة تعمل بدونه. + error_log("[complaint/ai] لا رد من النموذج للشكوى #$complaintId"); + jsonSuccess(['available' => false], "AI suggestion unavailable"); + } + + // ‏النموذج يغلّف JSON بأسوار ```json أحياناً رغم التعليمات. + $clean = trim(preg_replace('/^```(?:json)?|```$/mu', '', $text)); + $parsed = json_decode($clean, true); + + if (!is_array($parsed)) { + error_log("[complaint/ai] رد غير قابل للتحليل للشكوى #$complaintId: " + . mb_substr($clean, 0, 200)); + jsonSuccess(['available' => false], "AI suggestion unparsable"); + } + + // ── تحصين المخرجات ───────────────────────────────────── + // ‏النموذج قد يتجاهل القواعد. لا نمرّر رقماً منه إلى واجهة تصرف مالاً + // ‏دون قصّه هنا أولاً. + $amount = (float) ($parsed['compensation']['amount'] ?? 0); + $amount = max(0.0, min($amount, $hardCap)); + + $kind = $parsed['compensation']['kind'] ?? 'goodwill'; + if (!in_array($kind, ['refund', 'goodwill'], true)) $kind = 'goodwill'; + + $beneficiary = $parsed['compensation']['beneficiary'] ?? 'passenger'; + if (!in_array($beneficiary, ['passenger', 'driver'], true)) $beneficiary = 'passenger'; + + jsonSuccess([ + 'available' => true, + 'reason_code' => $parsed['reason_code'] ?? null, + 'fault_determination' => $parsed['fault_determination'] ?? 'unclear', + 'suggested_status' => $parsed['suggested_status'] ?? 'In Progress', + 'compensation' => [ + 'amount' => $amount, + 'kind' => $kind, + 'beneficiary' => $beneficiary, + 'currency' => $currency, + 'hard_cap' => $hardCap, + ], + 'customer_reply' => $parsed['customer_reply'] ?? '', + 'internal_note' => $parsed['internal_note'] ?? '', + 'confidence' => (float) ($parsed['confidence'] ?? 0), + ], "ok"); + +} catch (PDOException $e) { + error_log("[complaint/ai] " . $e->getMessage()); + jsonError("DB Error", 500); +} catch (Throwable $e) { + error_log("[complaint/ai] " . $e->getMessage()); + jsonSuccess(['available' => false], "AI suggestion unavailable"); +} diff --git a/backend/serviceapp/resolve_complaint.php b/backend/serviceapp/resolve_complaint.php index 9742c917..a6b8cce0 100644 --- a/backend/serviceapp/resolve_complaint.php +++ b/backend/serviceapp/resolve_complaint.php @@ -15,13 +15,9 @@ require_once __DIR__ . '/../ride/pricing/pricing_helper.php'; // ‏فحص الدور صراحةً — مجلد serviceapp ليس تحت بوّابة Admin/ المسارية في // ‏connect.php، وكل ملف هنا مسؤول عن حماية نفسه. (update_complaint.php // ‏الأصلي كان بلا فحص، فكان أي حامل JWT يغلق شكوى ويكتب قرارها.) -if ($role !== 'admin' && $role !== 'super_admin') { - http_response_code(403); - exit(json_encode([ - 'status' => 'failure', - 'message' => 'Forbidden. Support access required.', - ], JSON_UNESCAPED_UNICODE)); -} +// ‏البوّابة المسارية في connect.php تفرض بالفعل service/admin/super_admin +// ‏على كل ما تحت serviceapp/. لا نكرّرها هنا: نسختان من نفس الفحص تتباعدان +// ‏بصمت، وقد كتبتُ هذه أولاً بـ admin وحده فمنعت كل موظفي الخدمة. // ── قوائم مغلقة ───────────────────────────────────────────── // ‏نص حر في التصنيف يجعل التقارير عديمة المعنى بعد شهر. @@ -41,10 +37,6 @@ const COMPLAINT_REASONS = [ const COMPENSATION_KINDS = ['refund', 'goodwill']; const COMPENSATION_BENEFICIARIES = ['passenger', 'driver']; -// ‏سقف صلب على التعويض الواحد. صلاحية صرف المال بيد موظف دعم تحتاج حداً -// ‏أعلى لا يتجاوزه خطأ مطبعي (صفر زائد) ولا حساب مخترَق. -const COMPENSATION_ABSOLUTE_CAP = 50000.0; - $complaintId = filterRequest("complaint_id", 'int'); $newStatus = filterRequest("status"); $reasonCode = filterRequest("reason_code"); @@ -125,10 +117,6 @@ try { if (!in_array($compBeneficiary, COMPENSATION_BENEFICIARIES, true)) { jsonError("Invalid compensation_beneficiary"); } - if ($compAmount > COMPENSATION_ABSOLUTE_CAP) { - jsonError("Compensation exceeds the allowed limit"); - } - $beneficiaryId = $compBeneficiary === 'passenger' ? ($complaint['passenger_id'] ?? '') : ($complaint['driver_id'] ?? ''); @@ -144,6 +132,13 @@ try { ? (string) $kazan['currency'] : getCurrencyByCountry((string) ($kazan['country'] ?? 'Syria')); + // ‏السقف الصلب بعملة الدولة — يُفحص بعد معرفة العملة لا قبلها. + // ‏صلاحية صرف المال بيد موظف دعم تحتاج حداً لا يتجاوزه خطأ مطبعي. + $hardCap = getMoneyHardCap($currency); + if ($compAmount > $hardCap) { + jsonError("Compensation exceeds the allowed limit ($hardCap $currency)"); + } + if ($compKind === 'refund' && $rideId) { $stmtRide = $con->prepare("SELECT price FROM ride WHERE id = ? LIMIT 1"); $stmtRide->execute([$rideId]); diff --git a/backend/serviceapp/update_complaint.php b/backend/serviceapp/update_complaint.php index 955c05cb..351be916 100644 --- a/backend/serviceapp/update_complaint.php +++ b/backend/serviceapp/update_complaint.php @@ -1,16 +1,8 @@ 'failure', - 'message' => 'Forbidden. Support access required.', - ], JSON_UNESCAPED_UNICODE)); -} +// ‏كان هذا الملف بلا أي فحص دور. الحماية الآن من البوّابة المسارية في +// ‏connect.php (service/admin/super_admin لكل ما تحت serviceapp/). $id = filterRequest("id"); $status = filterRequest("statusComplaint"); diff --git a/siro_admin/lib/constant/links.dart b/siro_admin/lib/constant/links.dart index e037ae07..0a50d30a 100644 --- a/siro_admin/lib/constant/links.dart +++ b/siro_admin/lib/constant/links.dart @@ -464,6 +464,16 @@ class AppLink { static String serviceApp = "$server/serviceapp"; static String editCarPlate = "$serviceApp/editCarPlate.php"; static String getComplaintAllData = "$serviceApp/getComplaintAllData.php"; + + /// أداة التسوية: تحديث القضية + صرف تعويض في نداء واحد. + static String resolveComplaint = "$serviceApp/resolve_complaint.php"; + + /// توصية الذكاء الاصطناعي بمبلغ التعويض. تُستدعى بضغطة زر لا تلقائياً — + /// التحليل المحفوظ وقت تقديم الشكوى يُعرض مجاناً، وهذا يضيف المبلغ فقط. + static String complaintAiSuggest = "$serviceApp/complaint_ai_suggest.php"; + + /// مؤشرات دورة الشكوى: زمن أول رد، زمن الإغلاق، المفتوح الآن. + static String complaintSlaStats = "$serviceApp/complaint_sla_stats.php"; static String getComplaintAllDataForDriver = "$serviceApp/getComplaintAllDataForDriver.php"; static String addCriminalDocuments = "$authCaptin/addCriminalDocuments.php"; diff --git a/siro_admin/lib/controller/admin/complaint_resolve_controller.dart b/siro_admin/lib/controller/admin/complaint_resolve_controller.dart new file mode 100644 index 00000000..52496da8 --- /dev/null +++ b/siro_admin/lib/controller/admin/complaint_resolve_controller.dart @@ -0,0 +1,222 @@ +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 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>(); + + static const statuses = ['Open', 'In Progress', 'Resolved']; + + /// نفس القائمة المغلقة في serviceapp/resolve_complaint.php. أي تعديل + /// هناك يجب أن ينعكس هنا، وإلا رُفض الطلب بـ Invalid reason_code. + static const reasons = { + 'driver_behavior': 'سلوك السائق', + 'passenger_behavior': 'سلوك الراكب', + 'overcharge': 'سعر أعلى من المتوقع', + 'route_issue': 'مسار خاطئ أو أطول', + 'no_show': 'عدم حضور', + 'vehicle_condition': 'حالة المركبة', + 'app_issue': 'عطل تقني', + 'payment_issue': 'مشكلة دفع', + 'safety': 'سلامة', + 'other': 'أخرى', + }; + + static const faults = { + '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 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.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 save() async { + if (isSaving.value) return false; + + final payload = { + '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; + } + } +} diff --git a/siro_admin/lib/views/admin/complaints/complaint_list_page.dart b/siro_admin/lib/views/admin/complaints/complaint_list_page.dart index 41779954..1fa34624 100644 --- a/siro_admin/lib/views/admin/complaints/complaint_list_page.dart +++ b/siro_admin/lib/views/admin/complaints/complaint_list_page.dart @@ -3,6 +3,7 @@ import 'package:flutter/material.dart'; import 'package:google_fonts/google_fonts.dart'; import 'package:get/get.dart'; import '../../../controller/admin/complaint_controller.dart'; +import 'complaint_resolve_page.dart'; import '../../widgets/elevated_btn.dart'; import '../../widgets/my_textField.dart'; @@ -125,6 +126,20 @@ class ComplaintListPage extends StatelessWidget { child: ExpansionTile( tilePadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), leading: _buildStatusIndicator(c['statusComplaint'], statusColor, cs), + // ‏زر التسوية على البطاقة نفسها: البطاقة تعرض وتُحدّث الحالة فقط، + // ‏وأداة التعويض وتصنيف السبب في شاشة مستقلة. + trailing: IconButton( + tooltip: 'تسوية القضية', + icon: Icon(Icons.gavel_outlined, color: cs.primary, size: 20), + onPressed: () async { + final changed = await Get.to(() => ComplaintResolvePage( + complaint: Map.from(c as Map), + )); + if (changed == true) { + Get.find().getComplaints(); + } + }, + ), title: Text( c['description']?.toString() ?? 'بدون وصف', maxLines: 1, diff --git a/siro_admin/lib/views/admin/complaints/complaint_resolve_page.dart b/siro_admin/lib/views/admin/complaints/complaint_resolve_page.dart new file mode 100644 index 00000000..c426c312 --- /dev/null +++ b/siro_admin/lib/views/admin/complaints/complaint_resolve_page.dart @@ -0,0 +1,371 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import '../../../controller/admin/complaint_resolve_controller.dart'; + +/// شاشة تسوية شكوى — أداة الموظف لإغلاق قضية. +/// +/// ترتيب الأقسام مقصود: الوقائع أولاً، ثم التحليل المحفوظ، ثم القرار. +/// الموظف يقرأ قبل أن يقرّر، لا العكس. +class ComplaintResolvePage extends StatelessWidget { + const ComplaintResolvePage({super.key, required this.complaint}); + + final Map complaint; + + @override + Widget build(BuildContext context) { + final c = Get.put( + ComplaintResolveController(complaint), + tag: complaint['id'].toString(), + ); + + return Directionality( + textDirection: TextDirection.rtl, + child: Scaffold( + appBar: AppBar(title: Text('شكوى #${complaint['id']}')), + body: ListView( + padding: const EdgeInsets.all(16), + children: [ + _Facts(complaint: complaint), + const SizedBox(height: 16), + _SavedAnalysis(c: c), + const SizedBox(height: 16), + _SuggestionCard(c: c), + const SizedBox(height: 16), + _Decision(c: c), + const SizedBox(height: 24), + Obx(() => SizedBox( + height: 48, + child: ElevatedButton.icon( + onPressed: c.isSaving.value + ? null + : () async { + if (await c.save()) Get.back(result: true); + }, + icon: c.isSaving.value + ? const SizedBox( + width: 18, + height: 18, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.check), + label: const Text('حفظ التسوية'), + ), + )), + const SizedBox(height: 24), + ], + ), + ), + ); + } +} + +// ── الوقائع ──────────────────────────────────────────────── +class _Facts extends StatelessWidget { + const _Facts({required this.complaint}); + final Map complaint; + + @override + Widget build(BuildContext context) { + return _Section( + title: 'الوقائع', + icon: Icons.description_outlined, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _row('الرحلة', complaint['ride_id']), + _row('النوع', complaint['complaint_type']), + _row('تاريخ التقديم', complaint['date_filed']), + _row('الحالة الحالية', complaint['statusComplaint']), + const Divider(height: 20), + Text( + complaint['description']?.toString().trim().isNotEmpty == true + ? complaint['description'].toString() + : 'لا وصف', + style: const TextStyle(height: 1.5), + ), + ], + ), + ); + } + + Widget _row(String label, dynamic value) => Padding( + padding: const EdgeInsets.symmetric(vertical: 2), + child: Row( + children: [ + SizedBox( + width: 110, + child: Text(label, + style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 13)), + ), + Expanded(child: Text('${value ?? '—'}', style: const TextStyle(fontSize: 13))), + ], + ), + ); +} + +// ── التحليل المحفوظ ──────────────────────────────────────── +class _SavedAnalysis extends StatelessWidget { + const _SavedAnalysis({required this.c}); + final ComplaintResolveController c; + + @override + Widget build(BuildContext context) { + if (!c.hasSavedAnalysis) return const SizedBox.shrink(); + + return _Section( + // ‏هذا التحليل أنتجه Gemini وقت تقديم الشكوى وحُفظ في الجدول. + // ‏عرضه لا يكلّف نداءً ولا رصيداً — لذلك يظهر دائماً بلا زر. + title: 'تحليل وقت التقديم', + icon: Icons.auto_awesome_outlined, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (c.savedFault.isNotEmpty || c.savedNature.isNotEmpty) + Wrap(spacing: 8, runSpacing: 8, children: [ + if (c.savedFault.isNotEmpty) _Chip('المخطئ: ${c.savedFault}'), + if (c.savedNature.isNotEmpty) _Chip('الطبيعة: ${c.savedNature}'), + ]), + if (c.savedSolutions.isNotEmpty) ...[ + const SizedBox(height: 12), + const Text('حلول مقترحة:', style: TextStyle(fontWeight: FontWeight.bold)), + Text(c.savedSolutions, style: const TextStyle(height: 1.5)), + ], + if (c.savedPassengerReport.isNotEmpty) ...[ + const SizedBox(height: 12), + const Text('ما أُبلغ به الراكب:', style: TextStyle(fontWeight: FontWeight.bold)), + Text(c.savedPassengerReport, style: const TextStyle(height: 1.5)), + ], + if (c.savedDriverReport.isNotEmpty) ...[ + const SizedBox(height: 12), + const Text('ما أُبلغ به السائق:', style: TextStyle(fontWeight: FontWeight.bold)), + Text(c.savedDriverReport, style: const TextStyle(height: 1.5)), + ], + ], + ), + ); + } +} + +// ── توصية المبلغ ─────────────────────────────────────────── +class _SuggestionCard extends StatelessWidget { + const _SuggestionCard({required this.c}); + final ComplaintResolveController c; + + @override + Widget build(BuildContext context) { + return Obx(() { + final s = c.suggestion.value; + + return _Section( + title: 'توصية التسوية', + icon: Icons.psychology_outlined, + child: s == null + ? Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + 'يحلّل سجلّ الطرفين وتقييماتهما وتعليقات من تعاملوا معهما،' + ' ثم يقترح مبلغاً ورداً للعميل.', + style: TextStyle(fontSize: 13, height: 1.5), + ), + const SizedBox(height: 12), + OutlinedButton.icon( + onPressed: c.isSuggesting.value ? null : c.requestSuggestion, + icon: c.isSuggesting.value + ? const SizedBox( + width: 16, height: 16, + child: CircularProgressIndicator(strokeWidth: 2)) + : const Icon(Icons.lightbulb_outline), + label: Text(c.isSuggesting.value ? 'جارٍ التحليل…' : 'اقترح تسوية'), + ), + ], + ) + : Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Wrap(spacing: 8, runSpacing: 8, children: [ + _Chip('المخطئ: ${ComplaintResolveController.faults[s['fault_determination']] ?? s['fault_determination']}'), + _Chip('الثقة: ${((s['confidence'] ?? 0) * 100).round()}%'), + if ((s['compensation']?['amount'] ?? 0) > 0) + _Chip('التعويض: ${s['compensation']['amount']} ${s['compensation']['currency']}') + else + const _Chip('لا تعويض'), + ]), + if ((s['customer_reply'] ?? '').toString().isNotEmpty) ...[ + const SizedBox(height: 12), + const Text('ردّ مقترَح:', style: TextStyle(fontWeight: FontWeight.bold)), + Text(s['customer_reply'].toString(), + style: const TextStyle(height: 1.5)), + ], + if ((s['internal_note'] ?? '').toString().isNotEmpty) ...[ + const SizedBox(height: 8), + Text('ملاحظة داخلية: ${s['internal_note']}', + style: const TextStyle(fontSize: 12, color: Colors.grey)), + ], + const SizedBox(height: 12), + // ‏نسخ التوصية بفعل صريح لا تلقائياً: التعبئة الصامتة تجعل + // ‏الضغط على "حفظ" موافقةً ضمنية على رقم لم يقرأه أحد. + OutlinedButton.icon( + onPressed: c.applySuggestion, + icon: const Icon(Icons.arrow_downward), + label: const Text('انسخ إلى الحقول'), + ), + ], + ), + ); + }); + } +} + +// ── القرار ───────────────────────────────────────────────── +class _Decision extends StatelessWidget { + const _Decision({required this.c}); + final ComplaintResolveController c; + + @override + Widget build(BuildContext context) { + return _Section( + title: 'القرار', + icon: Icons.gavel_outlined, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Obx(() => DropdownButtonFormField( + value: c.status.value, + decoration: const InputDecoration(labelText: 'الحالة'), + items: ComplaintResolveController.statuses + .map((s) => DropdownMenuItem(value: s, child: Text(s))) + .toList(), + onChanged: (v) => c.status.value = v ?? c.status.value, + )), + const SizedBox(height: 12), + Obx(() => DropdownButtonFormField( + value: c.reasonCode.value, + decoration: const InputDecoration(labelText: 'تصنيف السبب'), + items: ComplaintResolveController.reasons.entries + .map((e) => DropdownMenuItem(value: e.key, child: Text(e.value))) + .toList(), + onChanged: (v) => c.reasonCode.value = v, + )), + const SizedBox(height: 12), + Obx(() => DropdownButtonFormField( + value: c.faultOn.value, + decoration: const InputDecoration(labelText: 'تحديد المسؤولية'), + items: ComplaintResolveController.faults.entries + .map((e) => DropdownMenuItem(value: e.key, child: Text(e.value))) + .toList(), + onChanged: (v) => c.faultOn.value = v, + )), + const SizedBox(height: 12), + TextField( + controller: c.resolutionCtrl, + maxLines: 4, + decoration: const InputDecoration( + labelText: 'نص القرار / الرد على العميل', + alignLabelWithHint: true, + ), + ), + const Divider(height: 28), + Obx(() => SwitchListTile( + contentPadding: EdgeInsets.zero, + title: const Text('صرف تعويض'), + subtitle: c.hardCap.value > 0 + ? Text('الحد الأقصى ${c.hardCap.value} ${c.currency.value}') + : const Text('اضغط "اقترح تسوية" لمعرفة الحد الأقصى'), + value: c.compEnabled.value, + onChanged: (v) => c.compEnabled.value = v, + )), + Obx(() => c.compEnabled.value + ? Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + TextField( + controller: c.compAmountCtrl, + keyboardType: + const TextInputType.numberWithOptions(decimal: true), + decoration: InputDecoration( + labelText: 'المبلغ', + suffixText: c.currency.value, + ), + ), + const SizedBox(height: 12), + DropdownButtonFormField( + value: c.compKind.value, + decoration: const InputDecoration(labelText: 'النوع'), + items: const [ + DropdownMenuItem( + value: 'refund', child: Text('استرجاع (لا يتجاوز قيمة الرحلة)')), + DropdownMenuItem( + value: 'goodwill', child: Text('رصيد اعتذار')), + ], + onChanged: (v) => c.compKind.value = v ?? 'goodwill', + ), + const SizedBox(height: 12), + DropdownButtonFormField( + value: c.compBeneficiary.value, + decoration: const InputDecoration(labelText: 'المستفيد'), + items: const [ + DropdownMenuItem(value: 'passenger', child: Text('الراكب')), + DropdownMenuItem(value: 'driver', child: Text('السائق')), + ], + onChanged: (v) => c.compBeneficiary.value = v ?? 'passenger', + ), + const SizedBox(height: 12), + TextField( + controller: c.compNoteCtrl, + decoration: const InputDecoration(labelText: 'سبب التعويض (اختياري)'), + ), + ], + ) + : const SizedBox.shrink()), + ], + ), + ); + } +} + +// ── عناصر مشتركة ─────────────────────────────────────────── +class _Section extends StatelessWidget { + const _Section({required this.title, required this.icon, required this.child}); + final String title; + final IconData icon; + final Widget child; + + @override + Widget build(BuildContext context) { + return Card( + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)), + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row(children: [ + Icon(icon, size: 18), + const SizedBox(width: 8), + Text(title, + style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 15)), + ]), + const SizedBox(height: 12), + child, + ], + ), + ), + ); + } +} + +class _Chip extends StatelessWidget { + const _Chip(this.text); + final String text; + + @override + Widget build(BuildContext context) => Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.primary.withOpacity(0.08), + borderRadius: BorderRadius.circular(20), + ), + child: Text(text, style: const TextStyle(fontSize: 12)), + ); +} diff --git a/siro_service/lib/constant/links.dart b/siro_service/lib/constant/links.dart index 4bf13f10..710e9449 100644 --- a/siro_service/lib/constant/links.dart +++ b/siro_service/lib/constant/links.dart @@ -179,6 +179,16 @@ class AppLink { static String addDriverWantWork = "$serviceApp/work/addDriverWantWork.php"; static String addCarWantWork = "$serviceApp/work/addCarWantWork.php"; static String getComplaintAllData = "$serviceApp/getComplaintAllData.php"; + + /// أداة التسوية: تحديث القضية + صرف تعويض في نداء واحد. + static String resolveComplaint = "$serviceApp/resolve_complaint.php"; + + /// توصية الذكاء الاصطناعي بمبلغ التعويض. تُستدعى بضغطة زر لا تلقائياً — + /// التحليل المحفوظ وقت تقديم الشكوى يُعرض مجاناً، وهذا يضيف المبلغ فقط. + static String complaintAiSuggest = "$serviceApp/complaint_ai_suggest.php"; + + /// مؤشرات دورة الشكوى: زمن أول رد، زمن الإغلاق، المفتوح الآن. + static String complaintSlaStats = "$serviceApp/complaint_sla_stats.php"; static String getComplaintAllDataForDriver = "$serviceApp/getComplaintAllDataForDriver.php"; static String rejectDriver = "$serviceApp/rejectDriver.php"; diff --git a/siro_service/lib/controller/mainController/complaints/complaint_list_controller.dart b/siro_service/lib/controller/mainController/complaints/complaint_list_controller.dart new file mode 100644 index 00000000..c154060f --- /dev/null +++ b/siro_service/lib/controller/mainController/complaints/complaint_list_controller.dart @@ -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 = >[].obs; + final isLoading = false.obs; + + /// فلتر الحالة. فارغ = الكل. + final statusFilter = ''.obs; + + List> 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 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.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; + } + } +} diff --git a/siro_service/lib/controller/mainController/complaints/complaint_resolve_controller.dart b/siro_service/lib/controller/mainController/complaints/complaint_resolve_controller.dart new file mode 100644 index 00000000..a4865e24 --- /dev/null +++ b/siro_service/lib/controller/mainController/complaints/complaint_resolve_controller.dart @@ -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 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>(); + + static const statuses = ['Open', 'In Progress', 'Resolved']; + + /// نفس القائمة المغلقة في serviceapp/resolve_complaint.php. أي تعديل + /// هناك يجب أن ينعكس هنا، وإلا رُفض الطلب بـ Invalid reason_code. + static const reasons = { + 'driver_behavior': 'سلوك السائق', + 'passenger_behavior': 'سلوك الراكب', + 'overcharge': 'سعر أعلى من المتوقع', + 'route_issue': 'مسار خاطئ أو أطول', + 'no_show': 'عدم حضور', + 'vehicle_condition': 'حالة المركبة', + 'app_issue': 'عطل تقني', + 'payment_issue': 'مشكلة دفع', + 'safety': 'سلامة', + 'other': 'أخرى', + }; + + static const faults = { + '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 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.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 save() async { + if (isSaving.value) return false; + + final payload = { + '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; + } + } +} diff --git a/siro_service/lib/controller/mainController/pages/complaint.dart b/siro_service/lib/controller/mainController/pages/complaint.dart index 74e54c38..42226ad9 100644 --- a/siro_service/lib/controller/mainController/pages/complaint.dart +++ b/siro_service/lib/controller/mainController/pages/complaint.dart @@ -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 = { + '': 'الكل', + '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 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(); + }, + ), + ); } } diff --git a/siro_service/lib/views/home/complaints/complaint_resolve_page.dart b/siro_service/lib/views/home/complaints/complaint_resolve_page.dart new file mode 100644 index 00000000..6dcb9a8c --- /dev/null +++ b/siro_service/lib/views/home/complaints/complaint_resolve_page.dart @@ -0,0 +1,371 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import '../../../controller/mainController/complaints/complaint_resolve_controller.dart'; + +/// شاشة تسوية شكوى — أداة الموظف لإغلاق قضية. +/// +/// ترتيب الأقسام مقصود: الوقائع أولاً، ثم التحليل المحفوظ، ثم القرار. +/// الموظف يقرأ قبل أن يقرّر، لا العكس. +class ComplaintResolvePage extends StatelessWidget { + const ComplaintResolvePage({super.key, required this.complaint}); + + final Map complaint; + + @override + Widget build(BuildContext context) { + final c = Get.put( + ComplaintResolveController(complaint), + tag: complaint['id'].toString(), + ); + + return Directionality( + textDirection: TextDirection.rtl, + child: Scaffold( + appBar: AppBar(title: Text('شكوى #${complaint['id']}')), + body: ListView( + padding: const EdgeInsets.all(16), + children: [ + _Facts(complaint: complaint), + const SizedBox(height: 16), + _SavedAnalysis(c: c), + const SizedBox(height: 16), + _SuggestionCard(c: c), + const SizedBox(height: 16), + _Decision(c: c), + const SizedBox(height: 24), + Obx(() => SizedBox( + height: 48, + child: ElevatedButton.icon( + onPressed: c.isSaving.value + ? null + : () async { + if (await c.save()) Get.back(result: true); + }, + icon: c.isSaving.value + ? const SizedBox( + width: 18, + height: 18, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.check), + label: const Text('حفظ التسوية'), + ), + )), + const SizedBox(height: 24), + ], + ), + ), + ); + } +} + +// ── الوقائع ──────────────────────────────────────────────── +class _Facts extends StatelessWidget { + const _Facts({required this.complaint}); + final Map complaint; + + @override + Widget build(BuildContext context) { + return _Section( + title: 'الوقائع', + icon: Icons.description_outlined, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _row('الرحلة', complaint['ride_id']), + _row('النوع', complaint['complaint_type']), + _row('تاريخ التقديم', complaint['date_filed']), + _row('الحالة الحالية', complaint['statusComplaint']), + const Divider(height: 20), + Text( + complaint['description']?.toString().trim().isNotEmpty == true + ? complaint['description'].toString() + : 'لا وصف', + style: const TextStyle(height: 1.5), + ), + ], + ), + ); + } + + Widget _row(String label, dynamic value) => Padding( + padding: const EdgeInsets.symmetric(vertical: 2), + child: Row( + children: [ + SizedBox( + width: 110, + child: Text(label, + style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 13)), + ), + Expanded(child: Text('${value ?? '—'}', style: const TextStyle(fontSize: 13))), + ], + ), + ); +} + +// ── التحليل المحفوظ ──────────────────────────────────────── +class _SavedAnalysis extends StatelessWidget { + const _SavedAnalysis({required this.c}); + final ComplaintResolveController c; + + @override + Widget build(BuildContext context) { + if (!c.hasSavedAnalysis) return const SizedBox.shrink(); + + return _Section( + // ‏هذا التحليل أنتجه Gemini وقت تقديم الشكوى وحُفظ في الجدول. + // ‏عرضه لا يكلّف نداءً ولا رصيداً — لذلك يظهر دائماً بلا زر. + title: 'تحليل وقت التقديم', + icon: Icons.auto_awesome_outlined, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (c.savedFault.isNotEmpty || c.savedNature.isNotEmpty) + Wrap(spacing: 8, runSpacing: 8, children: [ + if (c.savedFault.isNotEmpty) _Chip('المخطئ: ${c.savedFault}'), + if (c.savedNature.isNotEmpty) _Chip('الطبيعة: ${c.savedNature}'), + ]), + if (c.savedSolutions.isNotEmpty) ...[ + const SizedBox(height: 12), + const Text('حلول مقترحة:', style: TextStyle(fontWeight: FontWeight.bold)), + Text(c.savedSolutions, style: const TextStyle(height: 1.5)), + ], + if (c.savedPassengerReport.isNotEmpty) ...[ + const SizedBox(height: 12), + const Text('ما أُبلغ به الراكب:', style: TextStyle(fontWeight: FontWeight.bold)), + Text(c.savedPassengerReport, style: const TextStyle(height: 1.5)), + ], + if (c.savedDriverReport.isNotEmpty) ...[ + const SizedBox(height: 12), + const Text('ما أُبلغ به السائق:', style: TextStyle(fontWeight: FontWeight.bold)), + Text(c.savedDriverReport, style: const TextStyle(height: 1.5)), + ], + ], + ), + ); + } +} + +// ── توصية المبلغ ─────────────────────────────────────────── +class _SuggestionCard extends StatelessWidget { + const _SuggestionCard({required this.c}); + final ComplaintResolveController c; + + @override + Widget build(BuildContext context) { + return Obx(() { + final s = c.suggestion.value; + + return _Section( + title: 'توصية التسوية', + icon: Icons.psychology_outlined, + child: s == null + ? Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + 'يحلّل سجلّ الطرفين وتقييماتهما وتعليقات من تعاملوا معهما،' + ' ثم يقترح مبلغاً ورداً للعميل.', + style: TextStyle(fontSize: 13, height: 1.5), + ), + const SizedBox(height: 12), + OutlinedButton.icon( + onPressed: c.isSuggesting.value ? null : c.requestSuggestion, + icon: c.isSuggesting.value + ? const SizedBox( + width: 16, height: 16, + child: CircularProgressIndicator(strokeWidth: 2)) + : const Icon(Icons.lightbulb_outline), + label: Text(c.isSuggesting.value ? 'جارٍ التحليل…' : 'اقترح تسوية'), + ), + ], + ) + : Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Wrap(spacing: 8, runSpacing: 8, children: [ + _Chip('المخطئ: ${ComplaintResolveController.faults[s['fault_determination']] ?? s['fault_determination']}'), + _Chip('الثقة: ${((s['confidence'] ?? 0) * 100).round()}%'), + if ((s['compensation']?['amount'] ?? 0) > 0) + _Chip('التعويض: ${s['compensation']['amount']} ${s['compensation']['currency']}') + else + const _Chip('لا تعويض'), + ]), + if ((s['customer_reply'] ?? '').toString().isNotEmpty) ...[ + const SizedBox(height: 12), + const Text('ردّ مقترَح:', style: TextStyle(fontWeight: FontWeight.bold)), + Text(s['customer_reply'].toString(), + style: const TextStyle(height: 1.5)), + ], + if ((s['internal_note'] ?? '').toString().isNotEmpty) ...[ + const SizedBox(height: 8), + Text('ملاحظة داخلية: ${s['internal_note']}', + style: const TextStyle(fontSize: 12, color: Colors.grey)), + ], + const SizedBox(height: 12), + // ‏نسخ التوصية بفعل صريح لا تلقائياً: التعبئة الصامتة تجعل + // ‏الضغط على "حفظ" موافقةً ضمنية على رقم لم يقرأه أحد. + OutlinedButton.icon( + onPressed: c.applySuggestion, + icon: const Icon(Icons.arrow_downward), + label: const Text('انسخ إلى الحقول'), + ), + ], + ), + ); + }); + } +} + +// ── القرار ───────────────────────────────────────────────── +class _Decision extends StatelessWidget { + const _Decision({required this.c}); + final ComplaintResolveController c; + + @override + Widget build(BuildContext context) { + return _Section( + title: 'القرار', + icon: Icons.gavel_outlined, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Obx(() => DropdownButtonFormField( + value: c.status.value, + decoration: const InputDecoration(labelText: 'الحالة'), + items: ComplaintResolveController.statuses + .map((s) => DropdownMenuItem(value: s, child: Text(s))) + .toList(), + onChanged: (v) => c.status.value = v ?? c.status.value, + )), + const SizedBox(height: 12), + Obx(() => DropdownButtonFormField( + value: c.reasonCode.value, + decoration: const InputDecoration(labelText: 'تصنيف السبب'), + items: ComplaintResolveController.reasons.entries + .map((e) => DropdownMenuItem(value: e.key, child: Text(e.value))) + .toList(), + onChanged: (v) => c.reasonCode.value = v, + )), + const SizedBox(height: 12), + Obx(() => DropdownButtonFormField( + value: c.faultOn.value, + decoration: const InputDecoration(labelText: 'تحديد المسؤولية'), + items: ComplaintResolveController.faults.entries + .map((e) => DropdownMenuItem(value: e.key, child: Text(e.value))) + .toList(), + onChanged: (v) => c.faultOn.value = v, + )), + const SizedBox(height: 12), + TextField( + controller: c.resolutionCtrl, + maxLines: 4, + decoration: const InputDecoration( + labelText: 'نص القرار / الرد على العميل', + alignLabelWithHint: true, + ), + ), + const Divider(height: 28), + Obx(() => SwitchListTile( + contentPadding: EdgeInsets.zero, + title: const Text('صرف تعويض'), + subtitle: c.hardCap.value > 0 + ? Text('الحد الأقصى ${c.hardCap.value} ${c.currency.value}') + : const Text('اضغط "اقترح تسوية" لمعرفة الحد الأقصى'), + value: c.compEnabled.value, + onChanged: (v) => c.compEnabled.value = v, + )), + Obx(() => c.compEnabled.value + ? Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + TextField( + controller: c.compAmountCtrl, + keyboardType: + const TextInputType.numberWithOptions(decimal: true), + decoration: InputDecoration( + labelText: 'المبلغ', + suffixText: c.currency.value, + ), + ), + const SizedBox(height: 12), + DropdownButtonFormField( + value: c.compKind.value, + decoration: const InputDecoration(labelText: 'النوع'), + items: const [ + DropdownMenuItem( + value: 'refund', child: Text('استرجاع (لا يتجاوز قيمة الرحلة)')), + DropdownMenuItem( + value: 'goodwill', child: Text('رصيد اعتذار')), + ], + onChanged: (v) => c.compKind.value = v ?? 'goodwill', + ), + const SizedBox(height: 12), + DropdownButtonFormField( + value: c.compBeneficiary.value, + decoration: const InputDecoration(labelText: 'المستفيد'), + items: const [ + DropdownMenuItem(value: 'passenger', child: Text('الراكب')), + DropdownMenuItem(value: 'driver', child: Text('السائق')), + ], + onChanged: (v) => c.compBeneficiary.value = v ?? 'passenger', + ), + const SizedBox(height: 12), + TextField( + controller: c.compNoteCtrl, + decoration: const InputDecoration(labelText: 'سبب التعويض (اختياري)'), + ), + ], + ) + : const SizedBox.shrink()), + ], + ), + ); + } +} + +// ── عناصر مشتركة ─────────────────────────────────────────── +class _Section extends StatelessWidget { + const _Section({required this.title, required this.icon, required this.child}); + final String title; + final IconData icon; + final Widget child; + + @override + Widget build(BuildContext context) { + return Card( + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)), + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row(children: [ + Icon(icon, size: 18), + const SizedBox(width: 8), + Text(title, + style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 15)), + ]), + const SizedBox(height: 12), + child, + ], + ), + ), + ); + } +} + +class _Chip extends StatelessWidget { + const _Chip(this.text); + final String text; + + @override + Widget build(BuildContext context) => Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.primary.withOpacity(0.08), + borderRadius: BorderRadius.circular(20), + ), + child: Text(text, style: const TextStyle(fontSize: 12)), + ); +}