Update: 2026-08-07 05:03:39
This commit is contained in:
+14
-10
@@ -53,23 +53,28 @@ $role = $decoded->role ?? 'passenger';
|
|||||||
// update_complaint — أي أن أي حامل JWT صالح (راكب عادي) يقرأ بيانات
|
// update_complaint — أي أن أي حامل JWT صالح (راكب عادي) يقرأ بيانات
|
||||||
// السائقين والركّاب ويغلق الشكاوى. تحققنا أن siro_rider و siro_driver لا
|
// السائقين والركّاب ويغلق الشكاوى. تحققنا أن siro_rider و siro_driver لا
|
||||||
// يستدعيان أي نقطة تحت serviceapp/، فالإغلاق لا يكسر تطبيقات الجوال.
|
// يستدعيان أي نقطة تحت serviceapp/، فالإغلاق لا يكسر تطبيقات الجوال.
|
||||||
|
//
|
||||||
|
// ⚠️ لكل مجلد أدواره: تسجيل دخول تطبيق الخدمة (Admin/jwtService.php:51)
|
||||||
|
// يصدر الدور 'service' لا 'admin'. فرضُ admin على serviceapp/ كان
|
||||||
|
// سيمنع كل موظفي خدمة العملاء من كل نقاطهم.
|
||||||
$gatedDirs = array_filter([
|
$gatedDirs = array_filter([
|
||||||
realpath(__DIR__ . '/Admin'),
|
realpath(__DIR__ . '/Admin') => ['admin', 'super_admin'],
|
||||||
realpath(__DIR__ . '/serviceapp'),
|
realpath(__DIR__ . '/serviceapp') => ['service', 'admin', 'super_admin'],
|
||||||
]);
|
], fn($k) => $k !== false, ARRAY_FILTER_USE_KEY);
|
||||||
$script = realpath($_SERVER['SCRIPT_FILENAME'] ?? '');
|
|
||||||
|
|
||||||
$inGatedDir = false;
|
$script = realpath($_SERVER['SCRIPT_FILENAME'] ?? '');
|
||||||
|
|
||||||
|
$gateRoles = null;
|
||||||
if ($script) {
|
if ($script) {
|
||||||
foreach ($gatedDirs as $dir) {
|
foreach ($gatedDirs as $dir => $roles) {
|
||||||
if (str_starts_with($script, $dir . DIRECTORY_SEPARATOR)) {
|
if (str_starts_with($script, $dir . DIRECTORY_SEPARATOR)) {
|
||||||
$inGatedDir = true;
|
$gateRoles = $roles;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($inGatedDir) {
|
if ($gateRoles !== null) {
|
||||||
// نقاط تحت Admin/ تستدعيها تطبيقات الجوال بأدوار غير إدارية.
|
// نقاط تحت Admin/ تستدعيها تطبيقات الجوال بأدوار غير إدارية.
|
||||||
// تم التحقق من مواضع الاستدعاء الفعلية في siro_rider و siro_driver:
|
// تم التحقق من مواضع الاستدعاء الفعلية في siro_rider و siro_driver:
|
||||||
// errorApp.php → التطبيقان يبلّغان عن الأخطاء
|
// errorApp.php → التطبيقان يبلّغان عن الأخطاء
|
||||||
@@ -81,8 +86,7 @@ if ($inGatedDir) {
|
|||||||
];
|
];
|
||||||
|
|
||||||
if (!in_array(basename($script), $adminGateExempt, true)
|
if (!in_array(basename($script), $adminGateExempt, true)
|
||||||
&& $role !== 'admin'
|
&& !in_array($role, $gateRoles, true)
|
||||||
&& $role !== 'super_admin'
|
|
||||||
) {
|
) {
|
||||||
securityLog('Admin gate blocked non-admin request', [
|
securityLog('Admin gate blocked non-admin request', [
|
||||||
'script' => basename($script),
|
'script' => basename($script),
|
||||||
|
|||||||
@@ -55,88 +55,9 @@ $driverId = $ride['driver_id'];
|
|||||||
|
|
||||||
// --- دوال مساعدة لجلب البيانات (تم افتراض أن الدوال تصل إلى $con و $encryptionHelper عبر النطاق global أو تمريرها كـ arguments) ---
|
// --- دوال مساعدة لجلب البيانات (تم افتراض أن الدوال تصل إلى $con و $encryptionHelper عبر النطاق global أو تمريرها كـ arguments) ---
|
||||||
|
|
||||||
/**
|
// الدوال الثلاث انتقلت إلى complaint_context.php ليشاركها مستشار
|
||||||
* جلب بيانات ومعلومات تقييم السائق
|
// خدمة العملاء (serviceapp/complaint_ai_suggest.php) بلا تكرار.
|
||||||
*/
|
require_once __DIR__ . '/complaint_context.php';
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
// استدعاء الدوال لجلب البيانات
|
// استدعاء الدوال لجلب البيانات
|
||||||
$passengerProfile = getPassengerFullProfile($con, $encryptionHelper, $passengerId);
|
$passengerProfile = getPassengerFullProfile($con, $encryptionHelper, $passengerId);
|
||||||
$driverProfile = getDriverFullProfile($con, $encryptionHelper, $driverId);
|
$driverProfile = getDriverFullProfile($con, $encryptionHelper, $driverId);
|
||||||
|
|||||||
@@ -0,0 +1,95 @@
|
|||||||
|
<?php
|
||||||
|
// ============================================================
|
||||||
|
// complaint_context.php — جمع سياق الشكوى الكامل
|
||||||
|
//
|
||||||
|
// استُخرجت من add_solve_all.php ليشاركها مستشار خدمة العملاء.
|
||||||
|
// كانت هذه الدوال محبوسة في نقطة تقديم الشكوى، فبنيتُ نسخة أضعف منها
|
||||||
|
// لمستشار الدعم قبل أن أنتبه — وهذا الاستخراج يمنع تباعد النسختين.
|
||||||
|
//
|
||||||
|
// ما تجمعه: ملف السائق (تقييماته وتعليقات الركّاب عليه)، ملف الراكب
|
||||||
|
// (تقييماته وتعليقات السائقين عليه)، وسلوك القيادة في الرحلة نفسها.
|
||||||
|
// أي أن النموذج يرى "من هؤلاء" لا الشكوى وحدها.
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* جلب بيانات ومعلومات تقييم السائق
|
||||||
|
*/
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
@@ -166,7 +166,6 @@ function estimateDriverEarningsAdvantage(
|
|||||||
'competitor_name' => $data['competitor_name'],
|
'competitor_name' => $data['competitor_name'],
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
?>
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* صف التسعير الصحيح لدولة هذه الرحلة.
|
* صف التسعير الصحيح لدولة هذه الرحلة.
|
||||||
@@ -230,3 +229,35 @@ function getKazanForRide(PDO $con, $rideId, string $fallbackCountry = 'Syria', $
|
|||||||
return [];
|
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);
|
||||||
|
}
|
||||||
|
|||||||
@@ -12,9 +12,6 @@
|
|||||||
// • الأسعار ← جدول kazan عبر pricing_helper.php
|
// • الأسعار ← جدول kazan عبر pricing_helper.php
|
||||||
// ============================================================
|
// ============================================================
|
||||||
|
|
||||||
// سقف صلب فوق أي قيمة في القاعدة — يمنع خطأً إدارياً من خصم كارثي.
|
|
||||||
const PASSENGER_CANCEL_ABSOLUTE_CAP = 50000.0;
|
|
||||||
|
|
||||||
// حد أقصى للدقائق المحتسبة: سائق قَبِل ونسي الرحلة ساعة لا يتحول
|
// حد أقصى للدقائق المحتسبة: سائق قَبِل ونسي الرحلة ساعة لا يتحول
|
||||||
// نسيانه إلى فاتورة على الراكب.
|
// نسيانه إلى فاتورة على الراكب.
|
||||||
const PASSENGER_CANCEL_MAX_BILLABLE_MINUTES = 20;
|
const PASSENGER_CANCEL_MAX_BILLABLE_MINUTES = 20;
|
||||||
@@ -112,7 +109,8 @@ function computePassengerCancelFee(
|
|||||||
$fee = ($approachKm * getPerKmRate($carType, $kazan))
|
$fee = ($approachKm * getPerKmRate($carType, $kazan))
|
||||||
+ ($billableMinutes * getPerMinRate($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}كم"
|
error_log("[cancel_fee] ride=$rideId رسم إلغاء: {$approachKm}كم"
|
||||||
. " + {$billableMinutes}د = $fee $currency (سقف $cap)");
|
. " + {$billableMinutes}د = $fee $currency (سقف $cap)");
|
||||||
|
|||||||
@@ -30,10 +30,6 @@ if (filterRequest("penalty_fee") !== null) {
|
|||||||
. " (ride=$rideId driver=$driverId) — التعويض يُحسب على الخادم");
|
. " (ride=$rideId driver=$driverId) — التعويض يُحسب على الخادم");
|
||||||
}
|
}
|
||||||
|
|
||||||
// سقف صلب فوق أي قيمة في قاعدة البيانات — يمنع خطأ إدارياً أو صفاً تالفاً
|
|
||||||
// من تحويل التعويض إلى خصم كارثي.
|
|
||||||
const CANCEL_COMPENSATION_ABSOLUTE_CAP = 50000.0;
|
|
||||||
|
|
||||||
// حد أقصى لدقائق الانتظار المحتسبة: سائق نسي الرحلة مفتوحة ساعتين لا
|
// حد أقصى لدقائق الانتظار المحتسبة: سائق نسي الرحلة مفتوحة ساعتين لا
|
||||||
// يتحول انتظاره إلى فاتورة على الراكب.
|
// يتحول انتظاره إلى فاتورة على الراكب.
|
||||||
const CANCEL_MAX_BILLABLE_WAIT_MINUTES = 20;
|
const CANCEL_MAX_BILLABLE_WAIT_MINUTES = 20;
|
||||||
@@ -261,8 +257,11 @@ try {
|
|||||||
$perMin = getPerMinRate($kazan);
|
$perMin = getPerMinRate($kazan);
|
||||||
|
|
||||||
$compensation = ($approachKm * $perKm) + ($billableMinutes * $perMin);
|
$compensation = ($approachKm * $perKm) + ($billableMinutes * $perMin);
|
||||||
|
// سقف صلب بعملة الدولة لا رقماً مجرّداً: 50000 كانت
|
||||||
|
// بلا أثر على الليرة بعد حذف صفرين، وكارثية على الدينار.
|
||||||
|
$currencyCode = (string) ($kazan['currency'] ?? 'SYP');
|
||||||
$compensation = round(min($compensation, $compensationCap,
|
$compensation = round(min($compensation, $compensationCap,
|
||||||
CANCEL_COMPENSATION_ABSOLUTE_CAP), 2);
|
getMoneyHardCap($currencyCode)), 2);
|
||||||
|
|
||||||
error_log("[cancel_ride_by_driver] تعويض ride=$rideId:"
|
error_log("[cancel_ride_by_driver] تعويض ride=$rideId:"
|
||||||
. " {$approachKm}كم×$perKm + {$billableMinutes}د×$perMin"
|
. " {$approachKm}كم×$perKm + {$billableMinutes}د×$perMin"
|
||||||
|
|||||||
@@ -0,0 +1,223 @@
|
|||||||
|
<?php
|
||||||
|
// ============================================================
|
||||||
|
// serviceapp/complaint_ai_suggest.php — مستشار الشكاوى
|
||||||
|
//
|
||||||
|
// يقرأ الشكوى وسياق رحلتها ويقترح على الموظف: تصنيف السبب، تحديد
|
||||||
|
// المسؤولية، مبلغ تعويض، ومسودة ردّ للعميل.
|
||||||
|
//
|
||||||
|
// ⚠️ اقتراح لا قرار. لا يُغلق شكوى ولا يصرف ديناراً — الموظف يراجع ثم
|
||||||
|
// يضغط في resolve_complaint.php. نموذج لغوي يقرّر صرف المال وحده هو
|
||||||
|
// أسرع طريق لكارثة تشغيلية، ولأن يتعلّم أحدهم صياغة شكوى تُخرج مالاً.
|
||||||
|
//
|
||||||
|
// يبني على core/Services/SiroGeminiService.php القائم (نفس المفتاح
|
||||||
|
// ونفس آلية النداء المستخدمة في محرك التسويق والتسعير).
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
require_once __DIR__ . '/../connect.php';
|
||||||
|
require_once __DIR__ . '/../core/Services/SiroGeminiService.php';
|
||||||
|
require_once __DIR__ . '/../ride/pricing/pricing_helper.php';
|
||||||
|
// نفس دوال السياق التي يستخدمها add_solve_all.php وقت تقديم الشكوى:
|
||||||
|
// تقييمات الطرفين، تعليقات من ركبوا معه/معها، وسلوك القيادة في الرحلة.
|
||||||
|
require_once __DIR__ . '/../ride/feedBack/complaint_context.php';
|
||||||
|
|
||||||
|
$complaintId = filterRequest("complaint_id", 'int');
|
||||||
|
|
||||||
|
if (!$complaintId) {
|
||||||
|
jsonError("Missing complaint_id");
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$stmt = $con->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 = <<<PROMPT
|
||||||
|
أنت مساعد لموظف خدمة عملاء في تطبيق نقل ركّاب اسمه "سيرو".
|
||||||
|
مهمتك اقتراح تسوية لشكوى، لا اتخاذ القرار — الموظف هو من يقرّر.
|
||||||
|
|
||||||
|
بيانات الحالة (JSON):
|
||||||
|
{$contextJson}
|
||||||
|
|
||||||
|
قواعد ملزمة:
|
||||||
|
1. reason_code يجب أن يكون واحداً من: {$reasons}
|
||||||
|
2. المبلغ بعملة {$currency} ولا يتجاوز {$hardCap} إطلاقاً. هذا سقف صغير
|
||||||
|
عمداً: التعويض استرضاء لا تسوية نزاع. ما يستحق أكثر يُرفع للإدارة.
|
||||||
|
3. إن لم تكن المسؤولية واضحة من البيانات، اجعل المبلغ 0 واطلب توضيحاً.
|
||||||
|
وإن بدت الشكوى كيدية (سوابق الراكب وتقييماته تدل)، اجعله 0 وبيّن ذلك.
|
||||||
|
4. نوع التعويض: refund إن كان استرجاعاً لقيمة رحلة، أو goodwill إن كان استرضاءً.
|
||||||
|
5. الرد المقترَح للعميل بالعربية، مهذّب ومختصر، بلا وعود خارج ما اقترحته.
|
||||||
|
|
||||||
|
أجب بـ JSON فقط، بلا أي نص خارجه، بهذا الشكل:
|
||||||
|
{
|
||||||
|
"reason_code": "...",
|
||||||
|
"fault_determination": "driver | passenger | company | unclear",
|
||||||
|
"suggested_status": "In Progress | Resolved",
|
||||||
|
"compensation": {"amount": 0, "kind": "refund|goodwill", "beneficiary": "passenger|driver"},
|
||||||
|
"customer_reply": "...",
|
||||||
|
"internal_note": "...",
|
||||||
|
"confidence": 0.0
|
||||||
|
}
|
||||||
|
PROMPT;
|
||||||
|
|
||||||
|
// ── النداء ──────────────────────────────────────────────
|
||||||
|
$gemini = new SiroGeminiService();
|
||||||
|
$model = getenv('GEMINI_COMPLAINT_MODEL') ?: 'gemini-flash-lite-latest';
|
||||||
|
$raw = $gemini->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");
|
||||||
|
}
|
||||||
@@ -15,13 +15,9 @@ require_once __DIR__ . '/../ride/pricing/pricing_helper.php';
|
|||||||
// فحص الدور صراحةً — مجلد serviceapp ليس تحت بوّابة Admin/ المسارية في
|
// فحص الدور صراحةً — مجلد serviceapp ليس تحت بوّابة Admin/ المسارية في
|
||||||
// connect.php، وكل ملف هنا مسؤول عن حماية نفسه. (update_complaint.php
|
// connect.php، وكل ملف هنا مسؤول عن حماية نفسه. (update_complaint.php
|
||||||
// الأصلي كان بلا فحص، فكان أي حامل JWT يغلق شكوى ويكتب قرارها.)
|
// الأصلي كان بلا فحص، فكان أي حامل JWT يغلق شكوى ويكتب قرارها.)
|
||||||
if ($role !== 'admin' && $role !== 'super_admin') {
|
// البوّابة المسارية في connect.php تفرض بالفعل service/admin/super_admin
|
||||||
http_response_code(403);
|
// على كل ما تحت serviceapp/. لا نكرّرها هنا: نسختان من نفس الفحص تتباعدان
|
||||||
exit(json_encode([
|
// بصمت، وقد كتبتُ هذه أولاً بـ admin وحده فمنعت كل موظفي الخدمة.
|
||||||
'status' => 'failure',
|
|
||||||
'message' => 'Forbidden. Support access required.',
|
|
||||||
], JSON_UNESCAPED_UNICODE));
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── قوائم مغلقة ─────────────────────────────────────────────
|
// ── قوائم مغلقة ─────────────────────────────────────────────
|
||||||
// نص حر في التصنيف يجعل التقارير عديمة المعنى بعد شهر.
|
// نص حر في التصنيف يجعل التقارير عديمة المعنى بعد شهر.
|
||||||
@@ -41,10 +37,6 @@ const COMPLAINT_REASONS = [
|
|||||||
const COMPENSATION_KINDS = ['refund', 'goodwill'];
|
const COMPENSATION_KINDS = ['refund', 'goodwill'];
|
||||||
const COMPENSATION_BENEFICIARIES = ['passenger', 'driver'];
|
const COMPENSATION_BENEFICIARIES = ['passenger', 'driver'];
|
||||||
|
|
||||||
// سقف صلب على التعويض الواحد. صلاحية صرف المال بيد موظف دعم تحتاج حداً
|
|
||||||
// أعلى لا يتجاوزه خطأ مطبعي (صفر زائد) ولا حساب مخترَق.
|
|
||||||
const COMPENSATION_ABSOLUTE_CAP = 50000.0;
|
|
||||||
|
|
||||||
$complaintId = filterRequest("complaint_id", 'int');
|
$complaintId = filterRequest("complaint_id", 'int');
|
||||||
$newStatus = filterRequest("status");
|
$newStatus = filterRequest("status");
|
||||||
$reasonCode = filterRequest("reason_code");
|
$reasonCode = filterRequest("reason_code");
|
||||||
@@ -125,10 +117,6 @@ try {
|
|||||||
if (!in_array($compBeneficiary, COMPENSATION_BENEFICIARIES, true)) {
|
if (!in_array($compBeneficiary, COMPENSATION_BENEFICIARIES, true)) {
|
||||||
jsonError("Invalid compensation_beneficiary");
|
jsonError("Invalid compensation_beneficiary");
|
||||||
}
|
}
|
||||||
if ($compAmount > COMPENSATION_ABSOLUTE_CAP) {
|
|
||||||
jsonError("Compensation exceeds the allowed limit");
|
|
||||||
}
|
|
||||||
|
|
||||||
$beneficiaryId = $compBeneficiary === 'passenger'
|
$beneficiaryId = $compBeneficiary === 'passenger'
|
||||||
? ($complaint['passenger_id'] ?? '')
|
? ($complaint['passenger_id'] ?? '')
|
||||||
: ($complaint['driver_id'] ?? '');
|
: ($complaint['driver_id'] ?? '');
|
||||||
@@ -144,6 +132,13 @@ try {
|
|||||||
? (string) $kazan['currency']
|
? (string) $kazan['currency']
|
||||||
: getCurrencyByCountry((string) ($kazan['country'] ?? 'Syria'));
|
: getCurrencyByCountry((string) ($kazan['country'] ?? 'Syria'));
|
||||||
|
|
||||||
|
// السقف الصلب بعملة الدولة — يُفحص بعد معرفة العملة لا قبلها.
|
||||||
|
// صلاحية صرف المال بيد موظف دعم تحتاج حداً لا يتجاوزه خطأ مطبعي.
|
||||||
|
$hardCap = getMoneyHardCap($currency);
|
||||||
|
if ($compAmount > $hardCap) {
|
||||||
|
jsonError("Compensation exceeds the allowed limit ($hardCap $currency)");
|
||||||
|
}
|
||||||
|
|
||||||
if ($compKind === 'refund' && $rideId) {
|
if ($compKind === 'refund' && $rideId) {
|
||||||
$stmtRide = $con->prepare("SELECT price FROM ride WHERE id = ? LIMIT 1");
|
$stmtRide = $con->prepare("SELECT price FROM ride WHERE id = ? LIMIT 1");
|
||||||
$stmtRide->execute([$rideId]);
|
$stmtRide->execute([$rideId]);
|
||||||
|
|||||||
@@ -1,16 +1,8 @@
|
|||||||
<?php
|
<?php
|
||||||
require_once __DIR__ . '/../connect.php';
|
require_once __DIR__ . '/../connect.php';
|
||||||
|
|
||||||
// فحص الدور — كان غائباً تماماً بينما شقيقه getComplaintAllData.php يفحصه.
|
// كان هذا الملف بلا أي فحص دور. الحماية الآن من البوّابة المسارية في
|
||||||
// مجلد serviceapp خارج بوّابة Admin/ المسارية، فكل ملف يحمي نفسه، وهذا
|
// connect.php (service/admin/super_admin لكل ما تحت serviceapp/).
|
||||||
// كان مكشوفاً: أي حامل JWT صالح يقدر يغلق أي شكوى ويكتب نص قرارها.
|
|
||||||
if ($role !== 'admin' && $role !== 'super_admin') {
|
|
||||||
http_response_code(403);
|
|
||||||
exit(json_encode([
|
|
||||||
'status' => 'failure',
|
|
||||||
'message' => 'Forbidden. Support access required.',
|
|
||||||
], JSON_UNESCAPED_UNICODE));
|
|
||||||
}
|
|
||||||
|
|
||||||
$id = filterRequest("id");
|
$id = filterRequest("id");
|
||||||
$status = filterRequest("statusComplaint");
|
$status = filterRequest("statusComplaint");
|
||||||
|
|||||||
@@ -464,6 +464,16 @@ class AppLink {
|
|||||||
static String serviceApp = "$server/serviceapp";
|
static String serviceApp = "$server/serviceapp";
|
||||||
static String editCarPlate = "$serviceApp/editCarPlate.php";
|
static String editCarPlate = "$serviceApp/editCarPlate.php";
|
||||||
static String getComplaintAllData = "$serviceApp/getComplaintAllData.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 =
|
static String getComplaintAllDataForDriver =
|
||||||
"$serviceApp/getComplaintAllDataForDriver.php";
|
"$serviceApp/getComplaintAllDataForDriver.php";
|
||||||
static String addCriminalDocuments = "$authCaptin/addCriminalDocuments.php";
|
static String addCriminalDocuments = "$authCaptin/addCriminalDocuments.php";
|
||||||
|
|||||||
@@ -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<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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:google_fonts/google_fonts.dart';
|
import 'package:google_fonts/google_fonts.dart';
|
||||||
import 'package:get/get.dart';
|
import 'package:get/get.dart';
|
||||||
import '../../../controller/admin/complaint_controller.dart';
|
import '../../../controller/admin/complaint_controller.dart';
|
||||||
|
import 'complaint_resolve_page.dart';
|
||||||
import '../../widgets/elevated_btn.dart';
|
import '../../widgets/elevated_btn.dart';
|
||||||
import '../../widgets/my_textField.dart';
|
import '../../widgets/my_textField.dart';
|
||||||
|
|
||||||
@@ -125,6 +126,20 @@ class ComplaintListPage extends StatelessWidget {
|
|||||||
child: ExpansionTile(
|
child: ExpansionTile(
|
||||||
tilePadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
tilePadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||||
leading: _buildStatusIndicator(c['statusComplaint'], statusColor, cs),
|
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<String, dynamic>.from(c as Map),
|
||||||
|
));
|
||||||
|
if (changed == true) {
|
||||||
|
Get.find<ComplaintController>().getComplaints();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
title: Text(
|
title: Text(
|
||||||
c['description']?.toString() ?? 'بدون وصف',
|
c['description']?.toString() ?? 'بدون وصف',
|
||||||
maxLines: 1,
|
maxLines: 1,
|
||||||
|
|||||||
@@ -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<String, dynamic> 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<String, dynamic> 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<String>(
|
||||||
|
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<String>(
|
||||||
|
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<String>(
|
||||||
|
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<String>(
|
||||||
|
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<String>(
|
||||||
|
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)),
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -179,6 +179,16 @@ class AppLink {
|
|||||||
static String addDriverWantWork = "$serviceApp/work/addDriverWantWork.php";
|
static String addDriverWantWork = "$serviceApp/work/addDriverWantWork.php";
|
||||||
static String addCarWantWork = "$serviceApp/work/addCarWantWork.php";
|
static String addCarWantWork = "$serviceApp/work/addCarWantWork.php";
|
||||||
static String getComplaintAllData = "$serviceApp/getComplaintAllData.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 =
|
static String getComplaintAllDataForDriver =
|
||||||
"$serviceApp/getComplaintAllDataForDriver.php";
|
"$serviceApp/getComplaintAllDataForDriver.php";
|
||||||
static String rejectDriver = "$serviceApp/rejectDriver.php";
|
static String rejectDriver = "$serviceApp/rejectDriver.php";
|
||||||
|
|||||||
@@ -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:get/get.dart';
|
||||||
import 'package:siro_service/views/widgets/my_scafold.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 {
|
class Complaint extends StatelessWidget {
|
||||||
const Complaint({super.key});
|
const Complaint({super.key});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
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();
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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<String, dynamic> 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<String, dynamic> 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<String>(
|
||||||
|
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<String>(
|
||||||
|
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<String>(
|
||||||
|
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<String>(
|
||||||
|
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<String>(
|
||||||
|
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)),
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user