diff --git a/backend/Admin/v2/analytics/driver_ranking.php b/backend/Admin/v2/analytics/driver_ranking.php index bc37dfd0..7a6b2e5c 100644 --- a/backend/Admin/v2/analytics/driver_ranking.php +++ b/backend/Admin/v2/analytics/driver_ranking.php @@ -17,7 +17,7 @@ try { SUM(r.price) as total_revenue FROM driver d JOIN ride r ON d.id = r.driver_id - WHERE r.status = 'Finished' + WHERE LOWER(r.status) IN ('finished','completed') GROUP BY d.id, d.first_name, d.last_name, d.phone ORDER BY completed_rides DESC LIMIT 10 diff --git a/backend/Admin/v2/analytics/revenue.php b/backend/Admin/v2/analytics/revenue.php index 62de0dc7..40f98b3c 100644 --- a/backend/Admin/v2/analytics/revenue.php +++ b/backend/Admin/v2/analytics/revenue.php @@ -17,7 +17,7 @@ try { SUM(price - price_for_driver) as company_profit, COUNT(*) as total_rides FROM ride - WHERE status = 'Finished' + WHERE LOWER(status) IN ('finished','completed') AND created_at >= DATE_SUB(CURDATE(), INTERVAL 30 DAY) GROUP BY DATE(created_at) ORDER BY date ASC @@ -32,7 +32,7 @@ try { SUM(price - price_for_driver) as total_profit_all, AVG(price) as avg_ride_price FROM ride - WHERE status = 'Finished' + WHERE LOWER(status) IN ('finished','completed') AND created_at >= DATE_SUB(CURDATE(), INTERVAL 30 DAY) "); $stmt->execute(); diff --git a/backend/Admin/v2/financial/settlements.php b/backend/Admin/v2/financial/settlements.php index 44fcd176..9fe905c6 100644 --- a/backend/Admin/v2/financial/settlements.php +++ b/backend/Admin/v2/financial/settlements.php @@ -17,7 +17,7 @@ try { SUM(r.price_for_driver) as total_earned, COUNT(r.id) as total_rides FROM driver d - LEFT JOIN ride r ON d.id = r.driver_id AND r.status = 'Finished' + LEFT JOIN ride r ON d.id = r.driver_id AND LOWER(r.status) IN ('finished','completed') GROUP BY d.id HAVING total_earned > 0 ORDER BY total_earned DESC diff --git a/backend/Admin/v2/financial/stats.php b/backend/Admin/v2/financial/stats.php index 255bd1ee..bc76ce51 100644 --- a/backend/Admin/v2/financial/stats.php +++ b/backend/Admin/v2/financial/stats.php @@ -18,7 +18,7 @@ try { 0 as cash_payments, 0 as digital_payments FROM ride - WHERE status = 'Finished' + WHERE LOWER(status) IN ('finished','completed') "); $stmt->execute(); $stats = $stmt->fetch(PDO::FETCH_ASSOC); diff --git a/backend/Admin/v2/quality/driver_scorecard.php b/backend/Admin/v2/quality/driver_scorecard.php index 3d8b1160..0a2b0312 100644 --- a/backend/Admin/v2/quality/driver_scorecard.php +++ b/backend/Admin/v2/quality/driver_scorecard.php @@ -40,7 +40,7 @@ try { $stmt = $con->prepare(" SELECT COUNT(*) as total_rides, - SUM(CASE WHEN status = 'Finished' THEN 1 ELSE 0 END) as completed_rides, + SUM(CASE WHEN LOWER(status) IN ('finished','completed') THEN 1 ELSE 0 END) as completed_rides, SUM(CASE WHEN status = 'cancel' AND cancel_by = 'driver' THEN 1 ELSE 0 END) as driver_cancellations, SUM(CASE WHEN status = 'cancel' AND cancel_by = 'passenger' THEN 1 ELSE 0 END) as passenger_cancellations FROM ride diff --git a/backend/Admin/v2/realtime_dashboard.php b/backend/Admin/v2/realtime_dashboard.php index 044c515d..400acb59 100644 --- a/backend/Admin/v2/realtime_dashboard.php +++ b/backend/Admin/v2/realtime_dashboard.php @@ -26,12 +26,12 @@ try { $online_drivers = $stmt->fetchColumn(); // 3. إيرادات اليوم - $stmt = $con->prepare("SELECT IFNULL(SUM(price_for_passenger), 0) FROM ride WHERE status = 'Finished' AND DATE(created_at) = CURDATE()"); + $stmt = $con->prepare("SELECT IFNULL(SUM(price_for_passenger), 0) FROM ride WHERE LOWER(status) IN ('finished','completed') AND DATE(created_at) = CURDATE()"); $stmt->execute(); $revenue_today = $stmt->fetchColumn(); // إيرادات الأمس (للمقارنة) - $stmt = $con->prepare("SELECT IFNULL(SUM(price_for_passenger), 0) FROM ride WHERE status = 'Finished' AND DATE(created_at) = DATE_SUB(CURDATE(), INTERVAL 1 DAY)"); + $stmt = $con->prepare("SELECT IFNULL(SUM(price_for_passenger), 0) FROM ride WHERE LOWER(status) IN ('finished','completed') AND DATE(created_at) = DATE_SUB(CURDATE(), INTERVAL 1 DAY)"); $stmt->execute(); $revenue_yesterday = $stmt->fetchColumn(); diff --git a/dashboard/siro-admin/index.html b/dashboard/siro-admin/index.html index 1f80c863..281f3770 100644 --- a/dashboard/siro-admin/index.html +++ b/dashboard/siro-admin/index.html @@ -15,7 +15,7 @@ - + @@ -609,6 +609,6 @@ - + diff --git a/dashboard/siro-admin/js/app.js b/dashboard/siro-admin/js/app.js index 955c4222..fc843700 100644 --- a/dashboard/siro-admin/js/app.js +++ b/dashboard/siro-admin/js/app.js @@ -11,7 +11,7 @@ // Bump together with the ?v= query in index.html. Shown in the UI and in the // diagnostics report so "the deploy did nothing" can be answered with a fact // rather than a guess about caching. - const BUILD = '2026-07-25-7'; + const BUILD = '2026-07-25-8'; // ── Localisation ───────────────────────────────────────────────────────── // Arabic is the operators' language; English is kept because several screens @@ -1024,17 +1024,13 @@ }, { id: 'growth', group: 'Realtime & Analytics', icon: 'ph-trend-up', title: 'Growth', - subtitle: 'Daily signups for passengers and captains', - panels: [{ title: 'Growth', path: '/Admin/v2/analytics/growth.php' }], + subtitle: 'How many passengers and captains joined each day over the last 30 days', + custom: renderGrowth, }, { id: 'analyticsV2', group: 'Realtime & Analytics', icon: 'ph-chart-line', title: 'Advanced Analytics', subtitle: 'Revenue, ranking and dashboard aggregates from the v2 engine', - panels: [ - { title: 'Revenue', path: '/Admin/v2/analytics/revenue.php' }, - { title: 'Driver ranking', path: '/Admin/v2/analytics/driver_ranking.php' }, - { title: 'Dashboard data', path: '/Admin/v2/analytics/dashboard_data.php' }, - ], + custom: renderAnalytics, }, { id: 'financeV2', group: 'Finance', icon: 'ph-bank', title: 'Financial V2', @@ -1289,6 +1285,145 @@ }); } + + // ── Growth ─────────────────────────────────────────────────────────────── + // growth.php returns one row per day that had signups — days with none are + // simply absent. Plotting those rows directly would compress gaps and make + // a quiet week look like steady growth, so the series is expanded to a + // continuous 30-day axis with explicit zeros. + function densifyDays(rows, valueKey, days = 30) { + const byDate = new Map(); + (rows || []).forEach((r) => byDate.set(String(r.date).slice(0, 10), Number(r[valueKey]) || 0)); + + const series = []; + const today = new Date(); + for (let i = days - 1; i >= 0; i--) { + const d = new Date(today); + d.setDate(today.getDate() - i); + const iso = d.toISOString().slice(0, 10); + series.push({ + label: `${String(d.getDate()).padStart(2, '0')}/${String(d.getMonth() + 1).padStart(2, '0')}`, + value: byDate.get(iso) || 0, + }); + } + return series; + } + + const sumSeries = (series) => series.reduce((a, p) => a + p.value, 0); + + async function renderGrowth(host) { + host.innerHTML = '
Loading growth…
'; + + let payload; + try { + payload = await api('/Admin/v2/analytics/growth.php'); + } catch (err) { + if (handleApiError(err, 'growth')) return; + host.innerHTML = `
${esc(err.message)}
`; + return; + } + + const passengers = densifyDays(payload?.passenger_daily, 'new_passengers'); + const drivers = densifyDays(payload?.driver_daily, 'new_drivers'); + const totals = payload?.totals || {}; + + const joinedP = sumSeries(passengers); + const joinedD = sumSeries(drivers); + const activeDays = passengers.filter((p, i) => p.value || drivers[i].value).length; + + host.innerHTML = ` +
+ ${growthTile(fmtInt(totals.passengers), 'Passengers in total', 'ph-users', 'warning')} + ${growthTile(fmtInt(totals.drivers), 'Captains in total', 'ph-steering-wheel', 'info')} + ${growthTile(`+${fmtInt(joinedP)}`, 'Passengers joined · 30 days', 'ph-user-plus', 'primary')} + ${growthTile(`+${fmtInt(joinedD)}`, 'Captains joined · 30 days', 'ph-user-plus', 'success')} +
+ +
+
+

Signups per day last 30 days

+
+ Passengers + Captains +
+
+
+ ${activeDays === 0 + ? '
Nobody signed up in the last 30 days.
' + : `
+ ${activeDays} of 30 days had at least one signup +
`} +
`; + + drawMultiLine('growthChart', [ + { label: 'Passengers', color: '#6366f1', points: passengers }, + { label: 'Captains', color: '#10b981', points: drivers }, + ]); + } + + function growthTile(value, label, icon, tone) { + return ` +
+
+
+
${esc(value)}
+
${esc(label)}
+
+
+
+
`; + } + + // ── Advanced analytics ─────────────────────────────────────────────────── + async function renderAnalytics(host) { + host.innerHTML = ` +
+ + Revenue is counted from completed rides only. Total is what passengers paid; + platform share is what remains after the captain's cut. +
+
Loading revenue…
+
Loading captain ranking…
`; + + // Revenue + try { + const rev = await api('/Admin/v2/analytics/revenue.php'); + const daily = rev?.daily || []; + const summary = rev?.summary || {}; + const series = densifyDays(daily, 'total_revenue'); + const rides = densifyDays(daily, 'total_rides'); + + $('anRevenue').innerHTML = ` +
+

Revenue last 30 days

+
+
+
${summary.total_revenue_all == null ? '—' : fmtMoney(summary.total_revenue_all)}
Total collected
+
${summary.total_profit_all == null ? '—' : fmtMoney(summary.total_profit_all)}
Platform share
+
${summary.avg_ride_price == null ? '—' : fmtMoney(summary.avg_ride_price)}
Average fare
+
${fmtInt(sumSeries(rides))}
Completed rides
+
+
+ ${sumSeries(series) === 0 ? '
No completed rides carried a fare in this period.
' : ''}`; + + drawMultiLine('revenueTrend', [{ label: 'Revenue', color: '#06b6d4', points: series }]); + } catch (err) { + if (handleApiError(err, 'analytics-revenue')) return; + $('anRevenue').innerHTML = `
${esc(err.message)}
`; + } + + // Captain ranking + try { + const ranking = await api('/Admin/v2/analytics/driver_ranking.php'); + const body = $('anRanking'); + body.innerHTML = '

Captain ranking

'; + renderPayload(body.querySelector('.panel-body'), ranking); + } catch (err) { + if (handleApiError(err, 'analytics-ranking')) return; + $('anRanking').innerHTML = `
${esc(err.message)}
`; + } + } + // ── Blacklist & removal ────────────────────────────────────────────────── // Deletion here is a real DELETE against passengers/driver — the account and // its login are gone. The console therefore demands the phone number be @@ -2708,6 +2843,71 @@ }); } + + // Multiple series on one axis, sharing a scale so the lines are comparable. + function drawMultiLine(id, seriesList) { + chartData.set(id, { type: 'multi', series: seriesList }); + const c = prepareCanvas(id); + if (!c || !seriesList.length || !seriesList[0].points.length) return; + const { ctx, w, h } = c; + + const padL = 38, padR = 14, padTop = 14, padBottom = 26; + const count = seriesList[0].points.length; + const max = Math.max(1, ...seriesList.flatMap((s) => s.points.map((p) => p.value))); + const stepX = count > 1 ? (w - padL - padR) / (count - 1) : 0; + const y = (v) => h - padBottom - (v / max) * (h - padTop - padBottom); + + // grid + scale + ctx.strokeStyle = 'rgba(255,255,255,0.06)'; + ctx.fillStyle = '#64748b'; + ctx.font = '10px Inter, sans-serif'; + const ticks = Math.min(4, max); + for (let i = 0; i <= ticks; i++) { + const gy = padTop + i * (h - padTop - padBottom) / ticks; + ctx.beginPath(); ctx.moveTo(padL, gy); ctx.lineTo(w - padR, gy); ctx.stroke(); + ctx.textAlign = 'right'; + ctx.fillText(String(Math.round(max - i * max / ticks)), padL - 6, gy + 3); + } + + seriesList.forEach((serie) => { + const pts = serie.points.map((p, i) => ({ x: padL + i * stepX, y: y(p.value) })); + + const grad = ctx.createLinearGradient(0, padTop, 0, h - padBottom); + grad.addColorStop(0, serie.color + '55'); + grad.addColorStop(1, serie.color + '00'); + ctx.beginPath(); + pts.forEach((p, i) => (i ? ctx.lineTo(p.x, p.y) : ctx.moveTo(p.x, p.y))); + ctx.lineTo(pts[pts.length - 1].x, h - padBottom); + ctx.lineTo(pts[0].x, h - padBottom); + ctx.closePath(); + ctx.fillStyle = grad; + ctx.fill(); + + ctx.beginPath(); + pts.forEach((p, i) => (i ? ctx.lineTo(p.x, p.y) : ctx.moveTo(p.x, p.y))); + ctx.strokeStyle = serie.color; + ctx.lineWidth = 2.2; + ctx.lineJoin = 'round'; + ctx.stroke(); + + // mark only non-zero days: dots on a flat zero line are just noise + serie.points.forEach((p, i) => { + if (!p.value) return; + ctx.beginPath(); + ctx.arc(pts[i].x, pts[i].y, 3.2, 0, Math.PI * 2); + ctx.fillStyle = serie.color; + ctx.fill(); + }); + }); + + ctx.fillStyle = '#64748b'; + ctx.textAlign = 'center'; + const every = Math.ceil(count / 8); + seriesList[0].points.forEach((p, i) => { + if (i % every === 0 || i === count - 1) ctx.fillText(p.label, padL + i * stepX, h - 8); + }); + } + function drawBarChart(id, series) { chartData.set(id, { type: 'bar', series }); const c = prepareCanvas(id); @@ -2796,7 +2996,8 @@ function redrawCharts() { chartData.forEach((cfg, id) => { - if (cfg.type === 'line') drawLineChart(id, cfg.series); + if (cfg.type === 'multi') drawMultiLine(id, cfg.series); + else if (cfg.type === 'line') drawLineChart(id, cfg.series); else if (cfg.type === 'bar') drawBarChart(id, cfg.series); else drawDonut(id, cfg.series, cfg.legendEl); });