Update: 2026-08-07 05:03:39

This commit is contained in:
Hamza-Ayed
2026-08-07 05:03:39 +03:00
parent 8e7c4a7104
commit 977d238c84
18 changed files with 1830 additions and 128 deletions
+14 -10
View File
@@ -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),
+3 -82
View File
@@ -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;
}
+32 -1
View File
@@ -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);
}
+2 -4
View File
@@ -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)");
+4 -5
View File
@@ -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"
+223
View File
@@ -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");
}
+10 -15
View File
@@ -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]);
+2 -10
View File
@@ -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");
+10
View File
@@ -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)),
);
}
+10
View File
@@ -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;
}
}
}
@@ -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)),
);
}