قرار المالك 2026-07-27: باك إند سيرو PHP هو المعتمد، وتطبيقاته المجرّبة ميدانياً تحل محل إعادة البناء المؤرشفة. سيرو نفسه لم يُمسّ. الخريطة: backend · payment_server · loction_server · ride_server · passenger_server · docker · dashboard · stress_test → الجذر siro_rider → apps/rider siro_driver → apps/driver siro_admin → dashboards/admin siro_service → dashboards/service android_bot → apps/android_bot socialBot → apps/socialBot نُسخ المتعقَّب في git سيرو فقط عبر `git archive` (3,198 ملفاً / ~169 م.ب) لا `cp -r` — فاستُثنيت مخلفات البناء تلقائياً. بلا أي تعديل محتوى عمداً: كل ما يلي يصير فرقاً مقروءاً مقابل المصدر. لم يُستورد وسببه: siromove.com (الموقع التسويقي يبقى marketing/ في تريبز، سيرو فيه 8 ملفات) · docs و planning (تريبز له docs/ الخاص) · deploy.sh (ليس نشراً على سيرفر بل `git add . && git push origin --all` — فخّ في مستودع آخر) · transit_dashboard (بانتظار قرار مصير backend-transit و dashboards/transit-web). ⚠️ لا يبني بعد — ثلاثة نواقص متوقعة ومقصودة: 1. `.env` و `lib/env/env.g.dart` غير متعقَّبين في سيرو (أسرار لكل مستأجر): كل تطبيق فلاتر يحتاج .env خاصاً ثم توليد env.g.dart بـ build_runner. 2. إعدادات Firebase (9 ملفات google-services.json و GoogleService-Info.plist) يستبعدها .gitignore تريبز — ولكل مستأجر مشروع Firebase خاص أصلاً. 3. apps/driver في سيرو يشير إلى `../../Intaleq/packages/get` خارج المستودع → يجب ضمّ الحزم داخله أسوة بـ apps/rider. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1116 lines
42 KiB
PHP
1116 lines
42 KiB
PHP
<?php
|
|
header('Content-Type: text/html; charset=UTF-8');
|
|
?>
|
|
<!DOCTYPE html>
|
|
<html lang="ar" dir="rtl">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>Siro Analytics Dashboard</title>
|
|
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
|
|
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
|
|
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.4/dist/chart.umd.min.js"></script>
|
|
<style>
|
|
:root {
|
|
--bg: #0f1117;
|
|
--surface: #1a1d27;
|
|
--surface2: #242836;
|
|
--border: #2d3348;
|
|
--text: #e4e6f0;
|
|
--text2: #8b8fa8;
|
|
--accent: #6c5ce7;
|
|
--accent2: #00cec9;
|
|
--green: #00b894;
|
|
--red: #e74c3c;
|
|
--orange: #f39c12;
|
|
--blue: #0984e3;
|
|
--radius: 12px;
|
|
}
|
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
|
body {
|
|
font-family: -apple-system, 'Segoe UI', 'Noto Sans Arabic', sans-serif;
|
|
background: var(--bg);
|
|
color: var(--text);
|
|
min-height: 100vh;
|
|
direction: rtl;
|
|
}
|
|
.header {
|
|
background: var(--surface);
|
|
border-bottom: 1px solid var(--border);
|
|
padding: 16px 24px;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
position: sticky;
|
|
top: 0;
|
|
z-index: 1000;
|
|
}
|
|
.header h1 {
|
|
font-size: 20px;
|
|
font-weight: 700;
|
|
background: linear-gradient(135deg, var(--accent), var(--accent2));
|
|
-webkit-background-clip: text;
|
|
-webkit-text-fill-color: transparent;
|
|
}
|
|
.header-controls {
|
|
display: flex;
|
|
gap: 12px;
|
|
align-items: center;
|
|
}
|
|
.date-picker {
|
|
background: var(--surface2);
|
|
border: 1px solid var(--border);
|
|
color: var(--text);
|
|
padding: 8px 12px;
|
|
border-radius: 8px;
|
|
font-size: 14px;
|
|
}
|
|
.refresh-btn {
|
|
background: var(--accent);
|
|
color: #fff;
|
|
border: none;
|
|
padding: 8px 16px;
|
|
border-radius: 8px;
|
|
cursor: pointer;
|
|
font-size: 14px;
|
|
transition: opacity .2s;
|
|
}
|
|
.refresh-btn:hover { opacity: .8; }
|
|
|
|
/* Tabs */
|
|
.tabs {
|
|
display: flex;
|
|
gap: 0;
|
|
background: var(--surface);
|
|
border-bottom: 1px solid var(--border);
|
|
padding: 0 24px;
|
|
overflow-x: auto;
|
|
}
|
|
.tab {
|
|
padding: 14px 20px;
|
|
cursor: pointer;
|
|
border-bottom: 3px solid transparent;
|
|
color: var(--text2);
|
|
font-size: 14px;
|
|
font-weight: 500;
|
|
white-space: nowrap;
|
|
transition: all .2s;
|
|
}
|
|
.tab:hover { color: var(--text); }
|
|
.tab.active {
|
|
color: var(--accent);
|
|
border-bottom-color: var(--accent);
|
|
}
|
|
|
|
/* Content */
|
|
.tab-content { display: none; padding: 24px; }
|
|
.tab-content.active { display: block; }
|
|
|
|
/* Cards */
|
|
.cards {
|
|
display: grid;
|
|
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
|
gap: 16px;
|
|
margin-bottom: 24px;
|
|
}
|
|
.card {
|
|
background: var(--surface);
|
|
border: 1px solid var(--border);
|
|
border-radius: var(--radius);
|
|
padding: 20px;
|
|
}
|
|
.card-label {
|
|
font-size: 12px;
|
|
color: var(--text2);
|
|
margin-bottom: 8px;
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.5px;
|
|
}
|
|
.card-value {
|
|
font-size: 28px;
|
|
font-weight: 700;
|
|
}
|
|
.card-sub {
|
|
font-size: 12px;
|
|
color: var(--text2);
|
|
margin-top: 4px;
|
|
}
|
|
.positive { color: var(--green); }
|
|
.negative { color: var(--red); }
|
|
|
|
/* Charts */
|
|
.chart-grid {
|
|
display: grid;
|
|
grid-template-columns: repeat(auto-fit, minmax(450px, 1fr));
|
|
gap: 20px;
|
|
margin-bottom: 24px;
|
|
}
|
|
.chart-box {
|
|
background: var(--surface);
|
|
border: 1px solid var(--border);
|
|
border-radius: var(--radius);
|
|
padding: 20px;
|
|
}
|
|
.chart-box h3 {
|
|
font-size: 15px;
|
|
margin-bottom: 16px;
|
|
color: var(--text2);
|
|
}
|
|
.chart-box canvas { max-height: 300px; }
|
|
|
|
/* Map */
|
|
#map-container {
|
|
height: 500px;
|
|
border-radius: var(--radius);
|
|
overflow: hidden;
|
|
border: 1px solid var(--border);
|
|
margin-bottom: 24px;
|
|
}
|
|
.map-layers {
|
|
display: flex;
|
|
gap: 8px;
|
|
margin-bottom: 12px;
|
|
flex-wrap: wrap;
|
|
}
|
|
.layer-toggle {
|
|
background: var(--surface2);
|
|
border: 1px solid var(--border);
|
|
color: var(--text2);
|
|
padding: 6px 14px;
|
|
border-radius: 20px;
|
|
cursor: pointer;
|
|
font-size: 13px;
|
|
transition: all .2s;
|
|
}
|
|
.layer-toggle.active {
|
|
background: var(--accent);
|
|
color: #fff;
|
|
border-color: var(--accent);
|
|
}
|
|
|
|
/* Table */
|
|
.data-table {
|
|
width: 100%;
|
|
border-collapse: collapse;
|
|
font-size: 14px;
|
|
}
|
|
.data-table th, .data-table td {
|
|
padding: 12px 16px;
|
|
text-align: right;
|
|
border-bottom: 1px solid var(--border);
|
|
}
|
|
.data-table th {
|
|
background: var(--surface2);
|
|
color: var(--text2);
|
|
font-weight: 600;
|
|
font-size: 12px;
|
|
text-transform: uppercase;
|
|
}
|
|
.data-table tr:hover td { background: var(--surface2); }
|
|
|
|
.badge {
|
|
display: inline-block;
|
|
padding: 2px 8px;
|
|
border-radius: 10px;
|
|
font-size: 11px;
|
|
font-weight: 600;
|
|
}
|
|
.badge-red { background: rgba(231,76,60,.15); color: var(--red); }
|
|
.badge-green { background: rgba(0,184,148,.15); color: var(--green); }
|
|
.badge-orange { background: rgba(243,156,18,.15); color: var(--orange); }
|
|
.badge-blue { background: rgba(9,132,227,.15); color: var(--blue); }
|
|
|
|
.loading {
|
|
text-align: center;
|
|
padding: 60px;
|
|
color: var(--text2);
|
|
}
|
|
.loading-spinner {
|
|
width: 40px;
|
|
height: 40px;
|
|
border: 3px solid var(--border);
|
|
border-top-color: var(--accent);
|
|
border-radius: 50%;
|
|
animation: spin .8s linear infinite;
|
|
margin: 0 auto 16px;
|
|
}
|
|
@keyframes spin { to { transform: rotate(360deg); } }
|
|
|
|
.section-title {
|
|
font-size: 17px;
|
|
font-weight: 600;
|
|
margin-bottom: 16px;
|
|
padding-bottom: 8px;
|
|
border-bottom: 1px solid var(--border);
|
|
}
|
|
|
|
@media (max-width: 768px) {
|
|
.chart-grid { grid-template-columns: 1fr; }
|
|
.cards { grid-template-columns: repeat(2, 1fr); }
|
|
.header { flex-direction: column; gap: 12px; }
|
|
#map-container { height: 350px; }
|
|
}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
|
|
<div class="header">
|
|
<h1>Siro Analytics Dashboard</h1>
|
|
<div class="header-controls">
|
|
<input type="date" class="date-picker" id="datePicker" />
|
|
<button class="refresh-btn" onclick="loadAll()">تحديث</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="tabs">
|
|
<div class="tab active" data-tab="map">الخريطة التفاعلية</div>
|
|
<div class="tab" data-tab="market">السوق والتسعير</div>
|
|
<div class="tab" data-tab="fleet">الأسطول والجودة</div>
|
|
<div class="tab" data-tab="growth">النمو والاحتفاظ</div>
|
|
</div>
|
|
|
|
<!-- ═══════════════════════════════════════════════════════════ -->
|
|
<!-- تبويب 1: الخريطة التفاعلية -->
|
|
<!-- ═══════════════════════════════════════════════════════════ -->
|
|
<div class="tab-content active" id="tab-map">
|
|
<div class="cards" id="realtime-cards"></div>
|
|
|
|
<div class="section-title">خريطة فجوة العرض والطلب</div>
|
|
<div class="map-layers">
|
|
<button class="layer-toggle active" data-layer="gap">فجوة العرض/الطلب</button>
|
|
<button class="layer-toggle active" data-layer="heatmap">كثافة الطلب</button>
|
|
<button class="layer-toggle" data-layer="supply">مواقع الكباتن</button>
|
|
<button class="layer-toggle" data-layer="pricing">أسعار المنافسين</button>
|
|
<button class="layer-toggle" data-layer="zones">أعلى المناطق نشاطاً</button>
|
|
</div>
|
|
<div id="map-container"></div>
|
|
|
|
<div class="chart-grid">
|
|
<div class="chart-box">
|
|
<h3>توزيع الرحلات حسب الساعة (آخر 7 أيام)</h3>
|
|
<canvas id="hourlyChart"></canvas>
|
|
</div>
|
|
<div class="chart-box">
|
|
<h3>قمع الرحلات (حالات الطلبات)</h3>
|
|
<canvas id="funnelChart"></canvas>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- ═══════════════════════════════════════════════════════════ -->
|
|
<!-- تبويب 2: السوق والتسعير -->
|
|
<!-- ═══════════════════════════════════════════════════════════ -->
|
|
<div class="tab-content" id="tab-market">
|
|
<div class="cards" id="market-cards"></div>
|
|
|
|
<div class="chart-grid">
|
|
<div class="chart-box">
|
|
<h3>أسعار المنافسين (آخر 24 ساعة)</h3>
|
|
<canvas id="competitorChart"></canvas>
|
|
</div>
|
|
<div class="chart-box">
|
|
<h3>مؤشر تنافسية الأسعار (PCI) - تاريخي</h3>
|
|
<canvas id="pciChart"></canvas>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="chart-grid">
|
|
<div class="chart-box">
|
|
<h3>الحصة السوقية الأسبوعية</h3>
|
|
<canvas id="marketShareChart"></canvas>
|
|
</div>
|
|
<div class="chart-box">
|
|
<h3>الانحرافات والفرص (Anomalies)</h3>
|
|
<canvas id="anomaliesChart"></canvas>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- ═══════════════════════════════════════════════════════════ -->
|
|
<!-- تبويب 3: الأسطول والجودة -->
|
|
<!-- ═══════════════════════════════════════════════════════════ -->
|
|
<div class="tab-content" id="tab-fleet">
|
|
<div class="cards" id="fleet-cards"></div>
|
|
|
|
<div class="section-title">الشكاوى المفتوحة حسب النوع</div>
|
|
<div class="chart-grid">
|
|
<div class="chart-box">
|
|
<h3>توزيع الشكاوى</h3>
|
|
<canvas id="complaintsChart"></canvas>
|
|
</div>
|
|
<div class="chart-box">
|
|
<h3>الإيرادات اليومية vs أرباح الشركة</h3>
|
|
<canvas id="revenueProfitChart"></canvas>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="section-title" style="margin-top:24px">أعلى المناطق نشاطاً (آخر 4 أسابيع)</div>
|
|
<div class="chart-box">
|
|
<table class="data-table" id="topZonesTable">
|
|
<thead>
|
|
<tr>
|
|
<th>#</th>
|
|
<th>الموقع (Lat, Lng)</th>
|
|
<th>عدد الرحلات</th>
|
|
<th>متوسط السعر</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody></tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- ═══════════════════════════════════════════════════════════ -->
|
|
<!-- تبويب 4: النمو والاحتفاظ -->
|
|
<!-- ═══════════════════════════════════════════════════════════ -->
|
|
<div class="tab-content" id="tab-growth">
|
|
<div class="cards" id="growth-cards"></div>
|
|
|
|
<div class="chart-grid">
|
|
<div class="chart-box">
|
|
<h3>نمو المسجّلين الجدد (30 يوم)</h3>
|
|
<canvas id="growthChart"></canvas>
|
|
</div>
|
|
<div class="chart-box">
|
|
<h3>مقارنة أسبوع بأسبوع</h3>
|
|
<canvas id="weeklyChart"></canvas>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="chart-grid">
|
|
<div class="chart-box">
|
|
<h3>احتفاظ الركاب (Retention Cohort)</h3>
|
|
<canvas id="retentionChart"></canvas>
|
|
</div>
|
|
<div class="chart-box">
|
|
<h3>الإيرادات اليومية (30 يوم)</h3>
|
|
<canvas id="revenueChart"></canvas>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<script>
|
|
// ═════════════════════════════════════════════════════════════
|
|
// Global State
|
|
// ═════════════════════════════════════════════════════════════
|
|
const API_BASE = '../dashboard_data.php';
|
|
const TOKEN = new URLSearchParams(window.location.search).get('token') || '';
|
|
let DATA = {};
|
|
let map, mapLayers = {};
|
|
let charts = {};
|
|
|
|
if (!TOKEN) {
|
|
document.body.innerHTML = '<div style="text-align:center;padding:100px;color:#e74c3c;font-size:18px;">لم يتم تمرير رمز المصادقة. يرجى الدخول من لوحة الأدمن.</div>';
|
|
throw new Error('No auth token');
|
|
}
|
|
|
|
const datePicker = document.getElementById('datePicker');
|
|
datePicker.value = new Date().toISOString().slice(0, 10);
|
|
|
|
// ═════════════════════════════════════════════════════════════
|
|
// Tabs
|
|
// ═════════════════════════════════════════════════════════════
|
|
document.querySelectorAll('.tab').forEach(tab => {
|
|
tab.addEventListener('click', () => {
|
|
document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
|
|
document.querySelectorAll('.tab-content').forEach(c => c.classList.remove('active'));
|
|
tab.classList.add('active');
|
|
document.getElementById('tab-' + tab.dataset.tab).classList.add('active');
|
|
if (tab.dataset.tab === 'map' && map) setTimeout(() => map.invalidateSize(), 100);
|
|
});
|
|
});
|
|
|
|
// Layer toggles
|
|
document.querySelectorAll('.layer-toggle').forEach(btn => {
|
|
btn.addEventListener('click', () => {
|
|
btn.classList.toggle('active');
|
|
const layer = btn.dataset.layer;
|
|
if (mapLayers[layer]) {
|
|
if (btn.classList.contains('active')) {
|
|
map.addLayer(mapLayers[layer]);
|
|
} else {
|
|
map.removeLayer(mapLayers[layer]);
|
|
}
|
|
}
|
|
});
|
|
});
|
|
|
|
// ═════════════════════════════════════════════════════════════
|
|
// Data Loading
|
|
// ═════════════════════════════════════════════════════════════
|
|
async function fetchData(section = 'all') {
|
|
const date = datePicker.value;
|
|
const url = `${API_BASE}?date=${date}§ion=${section}`;
|
|
const res = await fetch(url, {
|
|
credentials: 'same-origin',
|
|
headers: { 'Authorization': `Bearer ${TOKEN}` }
|
|
});
|
|
if (!res.ok) {
|
|
if (res.status === 401 || res.status === 403) {
|
|
document.body.innerHTML = '<div style="text-align:center;padding:100px;color:#e74c3c;font-size:18px;">انتهت صلاحية الجلسة. يرجى إعادة الدخول من لوحة الأدمن.</div>';
|
|
throw new Error('Auth failed');
|
|
}
|
|
throw new Error(`HTTP ${res.status}`);
|
|
}
|
|
return res.json();
|
|
}
|
|
|
|
async function loadAll() {
|
|
try {
|
|
const res = await fetchData('all');
|
|
if (res.status !== 'success') {
|
|
console.error('API error:', res);
|
|
return;
|
|
}
|
|
DATA = res.data || {};
|
|
renderRealtimeCards();
|
|
renderMap();
|
|
renderHourlyChart();
|
|
renderFunnelChart();
|
|
renderMarketTab();
|
|
renderFleetTab();
|
|
renderGrowthTab();
|
|
} catch (e) {
|
|
console.error('Load failed:', e);
|
|
}
|
|
}
|
|
|
|
datePicker.addEventListener('change', loadAll);
|
|
|
|
// ═════════════════════════════════════════════════════════════
|
|
// Chart.js Defaults
|
|
// ═════════════════════════════════════════════════════════════
|
|
Chart.defaults.color = '#8b8fa8';
|
|
Chart.defaults.borderColor = '#2d3348';
|
|
Chart.defaults.font.family = '-apple-system, "Noto Sans Arabic", sans-serif';
|
|
|
|
function destroyChart(id) {
|
|
if (charts[id]) { charts[id].destroy(); delete charts[id]; }
|
|
}
|
|
|
|
function fmt(n) {
|
|
if (n == null) return '—';
|
|
if (typeof n === 'number') return n.toLocaleString('en-US', { maximumFractionDigits: 1 });
|
|
return n;
|
|
}
|
|
|
|
// ═════════════════════════════════════════════════════════════
|
|
// Tab 1: Map + Realtime
|
|
// ═════════════════════════════════════════════════════════════
|
|
function renderRealtimeCards() {
|
|
const d = DATA.realtime || {};
|
|
const revChange = d.revenue_yesterday > 0
|
|
? (((d.revenue_today - d.revenue_yesterday) / d.revenue_yesterday) * 100).toFixed(1)
|
|
: 0;
|
|
const revClass = revChange >= 0 ? 'positive' : 'negative';
|
|
|
|
document.getElementById('realtime-cards').innerHTML = `
|
|
<div class="card">
|
|
<div class="card-label">رحلات نشطة</div>
|
|
<div class="card-value">${fmt(d.active_rides)}</div>
|
|
</div>
|
|
<div class="card">
|
|
<div class="card-label">كباتن أونلاين</div>
|
|
<div class="card-value">${fmt(d.online_drivers)}</div>
|
|
</div>
|
|
<div class="card">
|
|
<div class="card-label">إيرادات اليوم</div>
|
|
<div class="card-value">${fmt(d.revenue_today)}</div>
|
|
<div class="card-sub ${revClass}">${revChange >= 0 ? '+' : ''}${revChange}% عن الأمس</div>
|
|
</div>
|
|
<div class="card">
|
|
<div class="card-label">شكاوى مفتوحة</div>
|
|
<div class="card-value">${fmt(d.open_complaints)}</div>
|
|
</div>
|
|
<div class="card">
|
|
<div class="card-label">رخص تنتهي قريباً</div>
|
|
<div class="card-value">${fmt(d.expiring_licenses)}</div>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
function renderMap() {
|
|
if (!map) {
|
|
map = L.map('map-container').setView([33.51, 36.29], 11);
|
|
L.tileLayer('https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png', {
|
|
attribution: '© OSM © CARTO',
|
|
maxZoom: 18
|
|
}).addTo(map);
|
|
}
|
|
|
|
Object.values(mapLayers).forEach(l => map.removeLayer(l));
|
|
mapLayers = {};
|
|
|
|
// Gap layer
|
|
const gapData = DATA.gap;
|
|
if (gapData && gapData.cells) {
|
|
const gapGroup = L.layerGroup();
|
|
gapData.cells.forEach(cell => {
|
|
const intensity = Math.min(cell.gap / 5, 1);
|
|
const color = cell.gap > 0
|
|
? `rgba(231, 76, 60, ${0.3 + intensity * 0.6})`
|
|
: `rgba(0, 184, 148, ${0.5})`;
|
|
L.circle([cell.lat, cell.lng], {
|
|
radius: 500,
|
|
fillColor: color,
|
|
fillOpacity: 0.6,
|
|
color: 'transparent',
|
|
weight: 0,
|
|
}).bindPopup(`
|
|
<div style="direction:rtl; font-family: sans-serif;">
|
|
<b>فجوة العرض/الطلب</b><br>
|
|
الطلب: ${cell.demand} | العرض: ${cell.supply}<br>
|
|
الفجوة: <b>${cell.gap}</b> | النسبة: ${cell.ratio}x
|
|
</div>
|
|
`).addTo(gapGroup);
|
|
});
|
|
mapLayers.gap = gapGroup;
|
|
gapGroup.addTo(map);
|
|
|
|
if (gapData.cells.length > 0) {
|
|
const bounds = gapData.cells.map(c => [c.lat, c.lng]);
|
|
map.fitBounds(bounds, { padding: [30, 30] });
|
|
}
|
|
}
|
|
|
|
// Heatmap (demand dots)
|
|
const heatData = DATA.heatmap;
|
|
if (heatData && heatData.data) {
|
|
const heatGroup = L.layerGroup();
|
|
const allCountries = Object.values(heatData.data).flat();
|
|
allCountries.slice(0, 3000).forEach(pt => {
|
|
L.circleMarker([pt.lat, pt.lng], {
|
|
radius: 3,
|
|
fillColor: '#f39c12',
|
|
fillOpacity: 0.4,
|
|
color: 'transparent',
|
|
weight: 0,
|
|
}).addTo(heatGroup);
|
|
});
|
|
mapLayers.heatmap = heatGroup;
|
|
heatGroup.addTo(map);
|
|
}
|
|
|
|
// Supply (driver locations) — from gap data
|
|
if (gapData && gapData.cells) {
|
|
const supplyGroup = L.layerGroup();
|
|
gapData.cells.filter(c => c.supply > 0).forEach(cell => {
|
|
L.circleMarker([cell.lat, cell.lng], {
|
|
radius: 4 + Math.min(cell.supply, 8),
|
|
fillColor: '#00b894',
|
|
fillOpacity: 0.6,
|
|
color: '#fff',
|
|
weight: 1,
|
|
}).bindPopup(`كباتن: ${cell.supply}`).addTo(supplyGroup);
|
|
});
|
|
mapLayers.supply = supplyGroup;
|
|
}
|
|
|
|
// Pricing grid
|
|
const pricingData = DATA.pricing;
|
|
if (pricingData && pricingData.grids) {
|
|
const pricingGroup = L.layerGroup();
|
|
Object.entries(pricingData.grids).forEach(([key, grid]) => {
|
|
if (key.includes('FALLBACK')) return;
|
|
const parts = key.split('_');
|
|
if (parts.length < 3) return;
|
|
const lat = parseFloat(parts[1]);
|
|
const lng = parseFloat(parts[2]);
|
|
if (isNaN(lat) || isNaN(lng)) return;
|
|
L.circleMarker([lat, lng], {
|
|
radius: 6,
|
|
fillColor: '#0984e3',
|
|
fillOpacity: 0.5,
|
|
color: '#fff',
|
|
weight: 1,
|
|
}).bindPopup(`
|
|
<div style="direction:rtl; font-family: sans-serif;">
|
|
<b>أسعار المنافسين</b><br>
|
|
المتوسط: ${grid.avg_price}<br>
|
|
الأرخص: ${grid.top_competitor} (${grid.cheapest_price})
|
|
</div>
|
|
`).addTo(pricingGroup);
|
|
});
|
|
mapLayers.pricing = pricingGroup;
|
|
}
|
|
|
|
// Top zones
|
|
const zonesData = DATA.zones;
|
|
if (zonesData && zonesData.zones) {
|
|
const zonesGroup = L.layerGroup();
|
|
zonesData.zones.forEach((z, i) => {
|
|
L.circleMarker([parseFloat(z.lat), parseFloat(z.lng)], {
|
|
radius: 8 + Math.min(parseInt(z.rides) / 10, 12),
|
|
fillColor: '#6c5ce7',
|
|
fillOpacity: 0.6,
|
|
color: '#fff',
|
|
weight: 2,
|
|
}).bindPopup(`
|
|
<div style="direction:rtl; font-family: sans-serif;">
|
|
<b>#${i+1} منطقة نشطة</b><br>
|
|
رحلات: ${z.rides}<br>
|
|
متوسط السعر: ${parseFloat(z.avg_price).toFixed(2)}
|
|
</div>
|
|
`).addTo(zonesGroup);
|
|
});
|
|
mapLayers.zones = zonesGroup;
|
|
}
|
|
|
|
// Sync layer toggles with actual layers
|
|
document.querySelectorAll('.layer-toggle').forEach(btn => {
|
|
const layer = btn.dataset.layer;
|
|
if (mapLayers[layer] && btn.classList.contains('active') && !map.hasLayer(mapLayers[layer])) {
|
|
map.addLayer(mapLayers[layer]);
|
|
}
|
|
});
|
|
}
|
|
|
|
function renderHourlyChart() {
|
|
const data = DATA.hourly;
|
|
if (!data || !data.hours) return;
|
|
destroyChart('hourly');
|
|
|
|
const labels = data.hours.map(h => h.hour + ':00');
|
|
const rides = data.hours.map(h => parseInt(h.rides));
|
|
const prices = data.hours.map(h => parseFloat(h.avg_price));
|
|
|
|
charts.hourly = new Chart(document.getElementById('hourlyChart'), {
|
|
type: 'bar',
|
|
data: {
|
|
labels,
|
|
datasets: [
|
|
{
|
|
label: 'عدد الرحلات',
|
|
data: rides,
|
|
backgroundColor: 'rgba(108,92,231,0.6)',
|
|
borderRadius: 4,
|
|
yAxisID: 'y',
|
|
},
|
|
{
|
|
label: 'متوسط السعر',
|
|
data: prices,
|
|
type: 'line',
|
|
borderColor: '#00cec9',
|
|
borderWidth: 2,
|
|
pointRadius: 3,
|
|
yAxisID: 'y1',
|
|
}
|
|
]
|
|
},
|
|
options: {
|
|
responsive: true,
|
|
interaction: { intersect: false, mode: 'index' },
|
|
scales: {
|
|
y: { position: 'right', title: { display: true, text: 'رحلات' } },
|
|
y1: { position: 'left', title: { display: true, text: 'سعر' }, grid: { display: false } },
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
function renderFunnelChart() {
|
|
const data = DATA.funnel;
|
|
if (!data || !data.statuses) return;
|
|
destroyChart('funnel');
|
|
|
|
const colors = {
|
|
'Finished': '#00b894', 'wait': '#f39c12', 'started': '#0984e3',
|
|
'arrived': '#6c5ce7', 'cancelled': '#e74c3c', 'timeout': '#636e72',
|
|
};
|
|
const labels = data.statuses.map(s => s.status);
|
|
const values = data.statuses.map(s => parseInt(s.count));
|
|
const bgColors = labels.map(l => colors[l] || '#8b8fa8');
|
|
|
|
charts.funnel = new Chart(document.getElementById('funnelChart'), {
|
|
type: 'doughnut',
|
|
data: {
|
|
labels,
|
|
datasets: [{ data: values, backgroundColor: bgColors, borderWidth: 0 }]
|
|
},
|
|
options: {
|
|
responsive: true,
|
|
plugins: { legend: { position: 'bottom' } }
|
|
}
|
|
});
|
|
}
|
|
|
|
// ═════════════════════════════════════════════════════════════
|
|
// Tab 2: Market & Pricing
|
|
// ═════════════════════════════════════════════════════════════
|
|
function renderMarketTab() {
|
|
// Market cards
|
|
const market = DATA.market;
|
|
let latestPci = '—', latestShare = '—';
|
|
if (market && market.countries) {
|
|
const firstCountry = Object.values(market.countries)[0];
|
|
if (firstCountry && firstCountry.length > 0) {
|
|
const latest = firstCountry[firstCountry.length - 1];
|
|
latestPci = parseFloat(latest.average_pci).toFixed(2);
|
|
latestShare = parseFloat(latest.market_share_percent).toFixed(1) + '%';
|
|
}
|
|
}
|
|
|
|
const pricing = DATA.pricing;
|
|
let gridCount = 0;
|
|
if (pricing && pricing.grids) gridCount = Object.keys(pricing.grids).length;
|
|
|
|
document.getElementById('market-cards').innerHTML = `
|
|
<div class="card">
|
|
<div class="card-label">مؤشر PCI الحالي</div>
|
|
<div class="card-value">${latestPci}</div>
|
|
<div class="card-sub">الهدف: 0.90 - 0.95</div>
|
|
</div>
|
|
<div class="card">
|
|
<div class="card-label">الحصة السوقية</div>
|
|
<div class="card-value">${latestShare}</div>
|
|
</div>
|
|
<div class="card">
|
|
<div class="card-label">مربعات تسعيرية نشطة</div>
|
|
<div class="card-value">${gridCount}</div>
|
|
</div>
|
|
`;
|
|
|
|
// Competitor price chart
|
|
const comp = DATA.competitor;
|
|
if (comp && comp.hourly) {
|
|
destroyChart('competitor');
|
|
const competitors = [...new Set(comp.hourly.map(r => r.competitor_name))];
|
|
const hours = [...new Set(comp.hourly.map(r => r.hour_bucket))];
|
|
const compColors = ['#e74c3c', '#0984e3', '#f39c12', '#00b894', '#6c5ce7'];
|
|
|
|
const datasets = competitors.map((name, i) => ({
|
|
label: name,
|
|
data: hours.map(h => {
|
|
const row = comp.hourly.find(r => r.competitor_name === name && r.hour_bucket === h);
|
|
return row ? parseFloat(row.avg_price) : null;
|
|
}),
|
|
borderColor: compColors[i % compColors.length],
|
|
borderWidth: 2,
|
|
pointRadius: 2,
|
|
tension: 0.3,
|
|
spanGaps: true,
|
|
}));
|
|
|
|
charts.competitor = new Chart(document.getElementById('competitorChart'), {
|
|
type: 'line',
|
|
data: { labels: hours.map(h => h.slice(11, 16)), datasets },
|
|
options: { responsive: true, interaction: { intersect: false, mode: 'index' } }
|
|
});
|
|
}
|
|
|
|
// PCI & Market Share charts
|
|
if (market && market.countries) {
|
|
const allData = Object.entries(market.countries);
|
|
if (allData.length > 0) {
|
|
const [cc, rows] = allData[0];
|
|
|
|
destroyChart('pci');
|
|
charts.pci = new Chart(document.getElementById('pciChart'), {
|
|
type: 'line',
|
|
data: {
|
|
labels: rows.map(r => r.report_date),
|
|
datasets: [{
|
|
label: `PCI (${cc})`,
|
|
data: rows.map(r => parseFloat(r.average_pci)),
|
|
borderColor: '#6c5ce7',
|
|
borderWidth: 2,
|
|
fill: true,
|
|
backgroundColor: 'rgba(108,92,231,0.1)',
|
|
tension: 0.3,
|
|
}]
|
|
},
|
|
options: { responsive: true }
|
|
});
|
|
|
|
destroyChart('marketShare');
|
|
charts.marketShare = new Chart(document.getElementById('marketShareChart'), {
|
|
type: 'bar',
|
|
data: {
|
|
labels: rows.map(r => r.report_date),
|
|
datasets: [{
|
|
label: `حصة سوقية % (${cc})`,
|
|
data: rows.map(r => parseFloat(r.market_share_percent)),
|
|
backgroundColor: 'rgba(0,206,201,0.6)',
|
|
borderRadius: 4,
|
|
}]
|
|
},
|
|
options: { responsive: true, scales: { y: { beginAtZero: true } } }
|
|
});
|
|
|
|
destroyChart('anomalies');
|
|
charts.anomalies = new Chart(document.getElementById('anomaliesChart'), {
|
|
type: 'bar',
|
|
data: {
|
|
labels: rows.map(r => r.report_date),
|
|
datasets: [{
|
|
label: 'انحرافات',
|
|
data: rows.map(r => parseInt(r.total_anomalies)),
|
|
backgroundColor: 'rgba(243,156,18,0.6)',
|
|
borderRadius: 4,
|
|
}]
|
|
},
|
|
options: { responsive: true }
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
// ═════════════════════════════════════════════════════════════
|
|
// Tab 3: Fleet & Quality
|
|
// ═════════════════════════════════════════════════════════════
|
|
function renderFleetTab() {
|
|
const rt = DATA.realtime || {};
|
|
const complaints = DATA.complaints;
|
|
const totalComplaints = complaints && complaints.by_type
|
|
? complaints.by_type.reduce((s, c) => s + parseInt(c.count), 0) : 0;
|
|
|
|
document.getElementById('fleet-cards').innerHTML = `
|
|
<div class="card">
|
|
<div class="card-label">كباتن أونلاين</div>
|
|
<div class="card-value">${fmt(rt.online_drivers)}</div>
|
|
</div>
|
|
<div class="card">
|
|
<div class="card-label">شكاوى مفتوحة</div>
|
|
<div class="card-value">${totalComplaints}</div>
|
|
</div>
|
|
<div class="card">
|
|
<div class="card-label">رخص تنتهي قريباً</div>
|
|
<div class="card-value">${fmt(rt.expiring_licenses)}</div>
|
|
</div>
|
|
`;
|
|
|
|
// Complaints chart
|
|
if (complaints && complaints.by_type) {
|
|
destroyChart('complaints');
|
|
const typeColors = ['#e74c3c','#f39c12','#0984e3','#6c5ce7','#00b894','#636e72','#fd79a8'];
|
|
charts.complaints = new Chart(document.getElementById('complaintsChart'), {
|
|
type: 'pie',
|
|
data: {
|
|
labels: complaints.by_type.map(c => c.complaint_type),
|
|
datasets: [{
|
|
data: complaints.by_type.map(c => parseInt(c.count)),
|
|
backgroundColor: typeColors,
|
|
borderWidth: 0,
|
|
}]
|
|
},
|
|
options: { responsive: true, plugins: { legend: { position: 'bottom' } } }
|
|
});
|
|
}
|
|
|
|
// Revenue vs Profit
|
|
const rev = DATA.revenue;
|
|
if (rev && rev.daily) {
|
|
destroyChart('revenueProfit');
|
|
charts.revenueProfit = new Chart(document.getElementById('revenueProfitChart'), {
|
|
type: 'line',
|
|
data: {
|
|
labels: rev.daily.map(r => r.date),
|
|
datasets: [
|
|
{
|
|
label: 'إيرادات',
|
|
data: rev.daily.map(r => parseFloat(r.total_revenue)),
|
|
borderColor: '#0984e3',
|
|
borderWidth: 2,
|
|
tension: 0.3,
|
|
},
|
|
{
|
|
label: 'أرباح الشركة',
|
|
data: rev.daily.map(r => parseFloat(r.company_profit)),
|
|
borderColor: '#00b894',
|
|
borderWidth: 2,
|
|
tension: 0.3,
|
|
}
|
|
]
|
|
},
|
|
options: {
|
|
responsive: true,
|
|
interaction: { intersect: false, mode: 'index' },
|
|
}
|
|
});
|
|
}
|
|
|
|
// Top zones table
|
|
const zones = DATA.zones;
|
|
if (zones && zones.zones) {
|
|
const tbody = document.querySelector('#topZonesTable tbody');
|
|
tbody.innerHTML = zones.zones.map((z, i) => `
|
|
<tr>
|
|
<td>${i + 1}</td>
|
|
<td>${parseFloat(z.lat).toFixed(4)}, ${parseFloat(z.lng).toFixed(4)}</td>
|
|
<td>${fmt(parseInt(z.rides))}</td>
|
|
<td>${parseFloat(z.avg_price).toFixed(2)}</td>
|
|
</tr>
|
|
`).join('');
|
|
}
|
|
}
|
|
|
|
// ═════════════════════════════════════════════════════════════
|
|
// Tab 4: Growth & Retention
|
|
// ═════════════════════════════════════════════════════════════
|
|
function renderGrowthTab() {
|
|
const growth = DATA.growth;
|
|
const weekly = DATA.weekly;
|
|
const retention = DATA.retention;
|
|
const rev = DATA.revenue;
|
|
|
|
// Growth cards
|
|
let totalP = '—', totalD = '—';
|
|
if (growth && growth.totals) {
|
|
totalP = fmt(growth.totals.passengers);
|
|
totalD = fmt(growth.totals.drivers);
|
|
}
|
|
|
|
let latestWeekRides = '—', weekOverWeek = '';
|
|
if (weekly && weekly.weeks && weekly.weeks.length >= 2) {
|
|
const last = weekly.weeks[weekly.weeks.length - 1];
|
|
const prev = weekly.weeks[weekly.weeks.length - 2];
|
|
latestWeekRides = fmt(parseInt(last.rides));
|
|
const change = ((parseInt(last.rides) - parseInt(prev.rides)) / parseInt(prev.rides) * 100).toFixed(1);
|
|
weekOverWeek = `<div class="card-sub ${change >= 0 ? 'positive' : 'negative'}">${change >= 0 ? '+' : ''}${change}% عن الأسبوع السابق</div>`;
|
|
}
|
|
|
|
document.getElementById('growth-cards').innerHTML = `
|
|
<div class="card">
|
|
<div class="card-label">إجمالي الركاب</div>
|
|
<div class="card-value">${totalP}</div>
|
|
</div>
|
|
<div class="card">
|
|
<div class="card-label">إجمالي الكباتن</div>
|
|
<div class="card-value">${totalD}</div>
|
|
</div>
|
|
<div class="card">
|
|
<div class="card-label">رحلات هذا الأسبوع</div>
|
|
<div class="card-value">${latestWeekRides}</div>
|
|
${weekOverWeek}
|
|
</div>
|
|
`;
|
|
|
|
// Growth chart
|
|
if (growth && growth.passengers) {
|
|
destroyChart('growth');
|
|
const allDates = [...new Set([
|
|
...growth.passengers.map(r => r.date),
|
|
...(growth.drivers || []).map(r => r.date)
|
|
])].sort();
|
|
|
|
const pMap = Object.fromEntries(growth.passengers.map(r => [r.date, parseInt(r.count)]));
|
|
const dMap = Object.fromEntries((growth.drivers || []).map(r => [r.date, parseInt(r.count)]));
|
|
|
|
charts.growth = new Chart(document.getElementById('growthChart'), {
|
|
type: 'line',
|
|
data: {
|
|
labels: allDates,
|
|
datasets: [
|
|
{
|
|
label: 'ركاب جدد',
|
|
data: allDates.map(d => pMap[d] || 0),
|
|
borderColor: '#6c5ce7',
|
|
borderWidth: 2,
|
|
fill: true,
|
|
backgroundColor: 'rgba(108,92,231,0.1)',
|
|
tension: 0.3,
|
|
},
|
|
{
|
|
label: 'كباتن جدد',
|
|
data: allDates.map(d => dMap[d] || 0),
|
|
borderColor: '#00cec9',
|
|
borderWidth: 2,
|
|
fill: true,
|
|
backgroundColor: 'rgba(0,206,201,0.1)',
|
|
tension: 0.3,
|
|
}
|
|
]
|
|
},
|
|
options: { responsive: true, interaction: { intersect: false, mode: 'index' } }
|
|
});
|
|
}
|
|
|
|
// Weekly comparison
|
|
if (weekly && weekly.weeks) {
|
|
destroyChart('weekly');
|
|
charts.weekly = new Chart(document.getElementById('weeklyChart'), {
|
|
type: 'bar',
|
|
data: {
|
|
labels: weekly.weeks.map(w => w.week_start),
|
|
datasets: [
|
|
{
|
|
label: 'رحلات',
|
|
data: weekly.weeks.map(w => parseInt(w.rides)),
|
|
backgroundColor: 'rgba(108,92,231,0.6)',
|
|
borderRadius: 4,
|
|
yAxisID: 'y',
|
|
},
|
|
{
|
|
label: 'إيرادات',
|
|
data: weekly.weeks.map(w => parseFloat(w.revenue)),
|
|
type: 'line',
|
|
borderColor: '#00b894',
|
|
borderWidth: 2,
|
|
tension: 0.3,
|
|
yAxisID: 'y1',
|
|
}
|
|
]
|
|
},
|
|
options: {
|
|
responsive: true,
|
|
interaction: { intersect: false, mode: 'index' },
|
|
scales: {
|
|
y: { position: 'right', title: { display: true, text: 'رحلات' } },
|
|
y1: { position: 'left', title: { display: true, text: 'إيرادات' }, grid: { display: false } },
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
// Retention chart
|
|
if (retention && retention.cohorts) {
|
|
destroyChart('retention');
|
|
charts.retention = new Chart(document.getElementById('retentionChart'), {
|
|
type: 'bar',
|
|
data: {
|
|
labels: retention.cohorts.map(c => `أسبوع ${c.weeks_since_signup}`),
|
|
datasets: [{
|
|
label: 'ركاب نشطون',
|
|
data: retention.cohorts.map(c => parseInt(c.active_passengers)),
|
|
backgroundColor: retention.cohorts.map((c, i) => {
|
|
const opacity = Math.max(0.3, 1 - i * 0.05);
|
|
return `rgba(108,92,231,${opacity})`;
|
|
}),
|
|
borderRadius: 4,
|
|
}]
|
|
},
|
|
options: { responsive: true, scales: { y: { beginAtZero: true } } }
|
|
});
|
|
}
|
|
|
|
// Revenue 30d
|
|
if (rev && rev.daily) {
|
|
destroyChart('revenue');
|
|
charts.revenue = new Chart(document.getElementById('revenueChart'), {
|
|
type: 'line',
|
|
data: {
|
|
labels: rev.daily.map(r => r.date),
|
|
datasets: [{
|
|
label: 'إيرادات يومية',
|
|
data: rev.daily.map(r => parseFloat(r.total_revenue)),
|
|
borderColor: '#0984e3',
|
|
borderWidth: 2,
|
|
fill: true,
|
|
backgroundColor: 'rgba(9,132,227,0.1)',
|
|
tension: 0.3,
|
|
}]
|
|
},
|
|
options: { responsive: true }
|
|
});
|
|
}
|
|
}
|
|
|
|
// ═════════════════════════════════════════════════════════════
|
|
// Init
|
|
// ═════════════════════════════════════════════════════════════
|
|
loadAll();
|
|
</script>
|
|
|
|
</body>
|
|
</html>
|