Files
tripz-llc/backend/Admin/v2/analytics/dashboard/index.php
T
Hamza-AyedandClaude Opus 5 4d8414c96b feat: استيراد كود سيرو إلى تريبز (سيرو @ecfe7568) — بلا تعديل
قرار المالك 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>
2026-07-27 05:14:13 +03:00

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}&section=${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: '&copy; OSM &copy; 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>