Add removal/blacklist tools, Arabic UI, and a persistent sign-in error
Authorisation: driver/deleteCaptain.php permanently deletes a captain and had no role check at all — any valid token, including a driver's or passenger's, could delete captains. It now requires super_admin. The two unblacklist endpoints had no role check either and now require an admin role. Console: - Blacklist & removal screen (super admin only): current blocks, permanent removal, and lifting a block. Deletion additionally requires the phone number to be retyped, because the row is deleted outright. - Arabic interface with full RTL layout and a language toggle. Sidebar, content offset, input affordances, toasts and directional icons all mirror; numbers, identifiers and the diagnostics output stay LTR. - Sign-in failures now render in the form and stay there. They were only shown as a toast, which disappears before it can be read — a locked account or a rejected device looked like the page simply returning to the login screen with no explanation. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
42f6d33efd
commit
81ee2acefd
@@ -1384,3 +1384,127 @@ textarea.form-input { text-align: start; }
|
||||
}
|
||||
|
||||
.card-header { gap: 1rem; flex-wrap: wrap; }
|
||||
|
||||
/* Destructive action button */
|
||||
.btn-danger {
|
||||
background: var(--danger-bg);
|
||||
border-color: rgba(244, 63, 94, 0.45);
|
||||
color: var(--danger);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.btn-danger:hover { background: rgba(244, 63, 94, 0.28); }
|
||||
|
||||
/* ==========================================================================
|
||||
Right-to-left layout (Arabic)
|
||||
The base layout is written LTR with physical properties, so RTL flips the
|
||||
fixed sidebar, the content offset and anything anchored to a screen edge.
|
||||
========================================================================== */
|
||||
.lang-toggle {
|
||||
font-weight: 700;
|
||||
font-size: 0.85rem;
|
||||
font-family: 'Outfit', sans-serif;
|
||||
}
|
||||
|
||||
[dir="rtl"] .sidebar {
|
||||
left: auto;
|
||||
right: 0;
|
||||
border-right: none;
|
||||
border-left: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
[dir="rtl"] .main-wrapper {
|
||||
margin-left: 0;
|
||||
margin-right: var(--sidebar-width);
|
||||
}
|
||||
|
||||
[dir="rtl"] .nav-item.active::before,
|
||||
[dir="rtl"] .nav-item::before {
|
||||
left: auto;
|
||||
right: 0;
|
||||
}
|
||||
|
||||
[dir="rtl"] .toast {
|
||||
right: auto;
|
||||
left: 24px;
|
||||
}
|
||||
|
||||
/* The icon sits at the inline start; in RTL that is the right edge, and the
|
||||
input's reserved padding must move with it. */
|
||||
[dir="rtl"] .form-input-group i {
|
||||
left: auto;
|
||||
right: 1rem;
|
||||
}
|
||||
|
||||
[dir="rtl"] .form-input-group .form-input {
|
||||
padding-left: 1rem;
|
||||
padding-right: 2.8rem;
|
||||
}
|
||||
|
||||
[dir="rtl"] .search-box i {
|
||||
left: auto;
|
||||
right: 0.9rem;
|
||||
}
|
||||
|
||||
[dir="rtl"] .search-box input {
|
||||
padding-left: 1rem;
|
||||
padding-right: 2.5rem;
|
||||
}
|
||||
|
||||
[dir="rtl"] .nav-count { margin-left: 0; margin-right: auto; }
|
||||
[dir="rtl"] .card-sub { margin-left: 0; margin-right: 0.4rem; }
|
||||
[dir="rtl"] .kv-row strong { text-align: left; }
|
||||
[dir="rtl"] .stop-list { padding-left: 0; padding-right: 1.2rem; }
|
||||
|
||||
/* Directional icons must mirror, plain symbols must not */
|
||||
[dir="rtl"] .route-cell .ph-arrow-right,
|
||||
[dir="rtl"] .btn .ph-caret-left,
|
||||
[dir="rtl"] .btn .ph-caret-right {
|
||||
transform: scaleX(-1);
|
||||
}
|
||||
|
||||
/* Numbers, code and diagnostics stay LTR even inside an RTL page */
|
||||
[dir="rtl"] .diagnostics,
|
||||
[dir="rtl"] .device-note code,
|
||||
[dir="rtl"] .stat-value,
|
||||
[dir="rtl"] .kpi-tile-value {
|
||||
direction: ltr;
|
||||
text-align: start;
|
||||
}
|
||||
|
||||
@media (max-width: 992px) {
|
||||
[dir="rtl"] .sidebar { transform: translateX(100%); }
|
||||
[dir="rtl"] .sidebar.open { transform: translateX(0); }
|
||||
[dir="rtl"] .main-wrapper { margin-right: 0; }
|
||||
}
|
||||
|
||||
/* Persistent sign-in error */
|
||||
.login-error {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 0.6rem;
|
||||
margin-top: 1.25rem;
|
||||
padding: 0.8rem 0.95rem;
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--danger-bg);
|
||||
border: 1px solid rgba(244, 63, 94, 0.4);
|
||||
color: var(--danger);
|
||||
font-size: 0.82rem;
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
/* display:flex would otherwise override the [hidden] attribute */
|
||||
.login-error[hidden] { display: none; }
|
||||
|
||||
.login-error i { font-size: 1.1rem; flex-shrink: 0; }
|
||||
.login-error div { display: flex; flex-direction: column; gap: 0.25rem; min-width: 0; }
|
||||
.login-error strong { color: var(--text-main); font-weight: 600; }
|
||||
|
||||
.login-error span {
|
||||
color: var(--text-muted);
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
font-size: 0.72rem;
|
||||
word-break: break-all;
|
||||
direction: ltr;
|
||||
text-align: start;
|
||||
}
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
<!-- ?v= must be bumped whenever css/main.css or js/app.js changes: the files
|
||||
are served straight off a bind mount, so without it browsers keep
|
||||
running the previously cached build after a deploy. -->
|
||||
<link rel="stylesheet" href="css/main.css?v=2026-07-25-2">
|
||||
<link rel="stylesheet" href="css/main.css?v=2026-07-25-6">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
@@ -27,17 +27,17 @@
|
||||
<i class="ph-bold ph-car"></i>
|
||||
<span>SIRO ADMIN</span>
|
||||
</div>
|
||||
<p class="auth-subtitle">Operations & Control Console</p>
|
||||
<p class="auth-subtitle" data-i18n="Operations & Control Console">Operations & Control Console</p>
|
||||
</div>
|
||||
|
||||
<form id="loginForm">
|
||||
<div class="secure-strip">
|
||||
<i class="ph-fill ph-shield-check"></i>
|
||||
<span>Secure session · device-bound token · WhatsApp OTP</span>
|
||||
<span data-i18n="Secure session · device-bound token · WhatsApp OTP">Secure session · device-bound token · WhatsApp OTP</span>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label" for="loginEmail">Admin Email / Phone</label>
|
||||
<label class="form-label" for="loginEmail" data-i18n="Admin Email / Phone">Admin Email / Phone</label>
|
||||
<div class="form-input-group">
|
||||
<input type="text" id="loginEmail" class="form-input" placeholder="you@siromove.com or +9627..." autocomplete="username" required>
|
||||
<i class="ph ph-envelope"></i>
|
||||
@@ -45,15 +45,19 @@
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label" for="loginPass">Password</label>
|
||||
<label class="form-label" for="loginPass" data-i18n="Password">Password</label>
|
||||
<div class="form-input-group">
|
||||
<input type="password" id="loginPass" class="form-input" placeholder="Enter your password" autocomplete="current-password" required>
|
||||
<i class="ph ph-lock-key"></i>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Stays on screen until the next attempt: a toast disappears before
|
||||
the operator can read why the sign-in failed. -->
|
||||
<div class="login-error" id="loginError" hidden></div>
|
||||
|
||||
<button type="submit" class="btn-primary" id="loginSubmitBtn" style="margin-top: 1.25rem;">
|
||||
<i class="ph-bold ph-sign-in"></i> <span>Sign In</span>
|
||||
<i class="ph-bold ph-sign-in"></i> <span data-i18n="Sign In">Sign In</span>
|
||||
</button>
|
||||
|
||||
<div class="device-note">
|
||||
@@ -80,41 +84,41 @@
|
||||
</div>
|
||||
|
||||
<nav class="sidebar-menu">
|
||||
<div class="menu-label">Operations</div>
|
||||
<div class="menu-label" data-i18n="Operations">Operations</div>
|
||||
<a class="nav-item active" data-view="dashboardView">
|
||||
<i class="ph ph-squares-four"></i>
|
||||
<span>Overview</span>
|
||||
<span data-i18n="Overview">Overview</span>
|
||||
</a>
|
||||
<a class="nav-item" data-view="ridesView">
|
||||
<i class="ph ph-path"></i>
|
||||
<span>Rides</span>
|
||||
<span data-i18n="Rides">Rides</span>
|
||||
</a>
|
||||
<a class="nav-item" data-view="driversView">
|
||||
<i class="ph ph-steering-wheel"></i>
|
||||
<span>Captains</span>
|
||||
<span data-i18n="Captains">Captains</span>
|
||||
</a>
|
||||
<a class="nav-item" data-view="passengersView">
|
||||
<i class="ph ph-users"></i>
|
||||
<span>Passengers</span>
|
||||
<span data-i18n="Passengers">Passengers</span>
|
||||
</a>
|
||||
|
||||
<div class="menu-label">Business</div>
|
||||
<div class="menu-label" data-i18n="Business">Business</div>
|
||||
<a class="nav-item" data-view="financialsView">
|
||||
<i class="ph ph-currency-circle-dollar"></i>
|
||||
<span>Financials</span>
|
||||
<span data-i18n="Financials">Financials</span>
|
||||
</a>
|
||||
<a class="nav-item" data-view="approvalsView">
|
||||
<i class="ph ph-user-check"></i>
|
||||
<span>Approvals</span>
|
||||
<span data-i18n="Approvals">Approvals</span>
|
||||
<span class="nav-count" id="approvalsCount" hidden>0</span>
|
||||
</a>
|
||||
<a class="nav-item" data-view="complaintsView">
|
||||
<i class="ph ph-warning-circle"></i>
|
||||
<span>Complaints</span>
|
||||
<span data-i18n="Complaints">Complaints</span>
|
||||
</a>
|
||||
<a class="nav-item" data-view="systemView">
|
||||
<i class="ph ph-shield-check"></i>
|
||||
<span>Session & Security</span>
|
||||
<span data-i18n="Session & Security">Session & Security</span>
|
||||
</a>
|
||||
</nav>
|
||||
|
||||
@@ -126,7 +130,7 @@
|
||||
<div class="role" id="userRole">—</div>
|
||||
</div>
|
||||
</div>
|
||||
<button class="btn-icon" id="logoutBtn" title="Sign Out">
|
||||
<button class="btn-icon" id="logoutBtn" title="Sign Out" data-i18n-title="Sign Out">
|
||||
<i class="ph ph-sign-out"></i>
|
||||
</button>
|
||||
</div>
|
||||
@@ -143,15 +147,16 @@
|
||||
</button>
|
||||
<div class="search-box">
|
||||
<i class="ph ph-magnifying-glass"></i>
|
||||
<input type="text" id="globalSearch" placeholder="Filter the current table…">
|
||||
<input type="text" id="globalSearch" placeholder="Filter the current table…" data-i18n-ph="Filter the current table…">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="header-right">
|
||||
<div class="live-indicator" id="connectionPill">
|
||||
<span class="pulse-dot"></span> <span id="connectionText">Connecting…</span>
|
||||
<span class="pulse-dot"></span> <span id="connectionText" data-i18n="Connecting…">Connecting…</span>
|
||||
</div>
|
||||
<button class="btn-icon" id="refreshBtn" title="Refresh data">
|
||||
<button class="btn-icon lang-toggle" id="langToggle" title="العربية / English">ع</button>
|
||||
<button class="btn-icon" id="refreshBtn" title="Refresh data" data-i18n-title="Refresh data">
|
||||
<i class="ph ph-arrows-clockwise"></i>
|
||||
</button>
|
||||
</div>
|
||||
@@ -163,8 +168,8 @@
|
||||
<section class="page-view active" id="dashboardView">
|
||||
<div class="page-header">
|
||||
<div class="page-title">
|
||||
<h1>Operations Overview</h1>
|
||||
<p>Live figures read directly from the production database</p>
|
||||
<h1 data-i18n="Operations Overview">Operations Overview</h1>
|
||||
<p data-i18n="Live figures read directly from the production database">Live figures read directly from the production database</p>
|
||||
</div>
|
||||
<div class="page-actions">
|
||||
<span class="stamp" id="lastUpdated">—</span>
|
||||
@@ -177,13 +182,13 @@
|
||||
<div class="stat-header">
|
||||
<div>
|
||||
<div class="stat-value" data-kpi="rides">—</div>
|
||||
<div class="stat-title">Total Rides</div>
|
||||
<div class="stat-title" data-i18n="Total Rides">Total Rides</div>
|
||||
</div>
|
||||
<div class="stat-icon primary"><i class="ph ph-path"></i></div>
|
||||
</div>
|
||||
<div class="stat-footer">
|
||||
<span class="trend-badge up"><i class="ph ph-plus"></i> <span data-kpi="ridesMonth">—</span></span>
|
||||
<span class="trend-text">this month</span>
|
||||
<span class="trend-text" data-i18n="this month">this month</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -191,13 +196,13 @@
|
||||
<div class="stat-header">
|
||||
<div>
|
||||
<div class="stat-value" data-kpi="completed">—</div>
|
||||
<div class="stat-title">Completed Rides</div>
|
||||
<div class="stat-title" data-i18n="Completed Rides">Completed Rides</div>
|
||||
</div>
|
||||
<div class="stat-icon success"><i class="ph ph-check-circle"></i></div>
|
||||
</div>
|
||||
<div class="stat-footer">
|
||||
<span class="trend-badge up"><i class="ph ph-percent"></i> <span data-kpi="completionRate">—</span></span>
|
||||
<span class="trend-text">completion rate</span>
|
||||
<span class="trend-text" data-i18n="completion rate">completion rate</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -205,13 +210,13 @@
|
||||
<div class="stat-header">
|
||||
<div>
|
||||
<div class="stat-value" data-kpi="drivers">—</div>
|
||||
<div class="stat-title">Registered Captains</div>
|
||||
<div class="stat-title" data-i18n="Registered Captains">Registered Captains</div>
|
||||
</div>
|
||||
<div class="stat-icon info"><i class="ph ph-steering-wheel"></i></div>
|
||||
</div>
|
||||
<div class="stat-footer">
|
||||
<span class="trend-badge up"><i class="ph ph-plus"></i> <span data-kpi="driversMonth">—</span></span>
|
||||
<span class="trend-text">joined this month</span>
|
||||
<span class="trend-text" data-i18n="joined this month">joined this month</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -219,13 +224,13 @@
|
||||
<div class="stat-header">
|
||||
<div>
|
||||
<div class="stat-value" data-kpi="passengers">—</div>
|
||||
<div class="stat-title">Registered Passengers</div>
|
||||
<div class="stat-title" data-i18n="Registered Passengers">Registered Passengers</div>
|
||||
</div>
|
||||
<div class="stat-icon warning"><i class="ph ph-users"></i></div>
|
||||
</div>
|
||||
<div class="stat-footer">
|
||||
<span class="trend-badge up"><i class="ph ph-plus"></i> <span data-kpi="passengersMonth">—</span></span>
|
||||
<span class="trend-text">joined this month</span>
|
||||
<span class="trend-text" data-i18n="joined this month">joined this month</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -234,7 +239,7 @@
|
||||
<div class="dashboard-grid">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h3 class="card-title">Rides per Day <span class="card-sub">last 21 days</span></h3>
|
||||
<h3 class="card-title"><span data-i18n="Rides per Day">Rides per Day</span> <span class="card-sub" data-i18n="last 21 days">last 21 days</span></h3>
|
||||
</div>
|
||||
<div class="chart-container">
|
||||
<canvas id="ridesTrendChart"></canvas>
|
||||
@@ -243,7 +248,7 @@
|
||||
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h3 class="card-title">Ride Outcomes</h3>
|
||||
<h3 class="card-title" data-i18n="Ride Outcomes">Ride Outcomes</h3>
|
||||
</div>
|
||||
<div class="chart-container donut-wrap">
|
||||
<canvas id="statusDonut"></canvas>
|
||||
@@ -255,7 +260,7 @@
|
||||
<div class="dashboard-grid">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h3 class="card-title">Demand by Time of Day</h3>
|
||||
<h3 class="card-title" data-i18n="Demand by Time of Day">Demand by Time of Day</h3>
|
||||
</div>
|
||||
<div class="chart-container" style="height:200px;">
|
||||
<canvas id="timeOfDayChart"></canvas>
|
||||
@@ -264,7 +269,7 @@
|
||||
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h3 class="card-title">Service Mix</h3>
|
||||
<h3 class="card-title" data-i18n="Service Mix">Service Mix</h3>
|
||||
</div>
|
||||
<div class="mini-list" id="serviceMix"></div>
|
||||
</div>
|
||||
@@ -276,7 +281,7 @@
|
||||
<div class="page-header">
|
||||
<div class="page-title">
|
||||
<h1>Rides</h1>
|
||||
<p>Trips, assigned captains, fares and live status</p>
|
||||
<p data-i18n="Trips, assigned captains, fares and live status">Trips, assigned captains, fares and live status</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -302,13 +307,13 @@
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Trip</th>
|
||||
<th>Passenger</th>
|
||||
<th>Captain</th>
|
||||
<th>Route</th>
|
||||
<th>Fare</th>
|
||||
<th data-i18n="Trip">Trip</th>
|
||||
<th data-i18n="Passenger">Passenger</th>
|
||||
<th data-i18n="Captain">Captain</th>
|
||||
<th data-i18n="Route">Route</th>
|
||||
<th data-i18n="Fare">Fare</th>
|
||||
<th>Status</th>
|
||||
<th>Created</th>
|
||||
<th data-i18n="Created">Created</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -328,7 +333,7 @@
|
||||
<div class="page-header">
|
||||
<div class="page-title">
|
||||
<h1>Captains</h1>
|
||||
<p>Fleet roster, ratings and trip volume</p>
|
||||
<p data-i18n="Fleet roster, ratings and trip volume">Fleet roster, ratings and trip volume</p>
|
||||
</div>
|
||||
<div class="page-actions">
|
||||
<button class="btn btn-secondary btn-sm" id="driversPrev"><i class="ph ph-caret-left"></i></button>
|
||||
@@ -350,7 +355,7 @@
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th data-i18n="ID">ID</th>
|
||||
<th>Name</th>
|
||||
<th>Phone</th>
|
||||
<th>Email</th>
|
||||
@@ -371,7 +376,7 @@
|
||||
<div class="page-header">
|
||||
<div class="page-title">
|
||||
<h1>Passengers</h1>
|
||||
<p>Most active customer accounts by trip count</p>
|
||||
<p data-i18n="Most active customer accounts by trip count">Most active customer accounts by trip count</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -388,7 +393,7 @@
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th data-i18n="ID">ID</th>
|
||||
<th>Name</th>
|
||||
<th>Contact</th>
|
||||
<th>Trips</th>
|
||||
@@ -409,27 +414,27 @@
|
||||
<div class="page-header">
|
||||
<div class="page-title">
|
||||
<h1>Financials</h1>
|
||||
<p>Earnings, fares and distance aggregates across all recorded trips</p>
|
||||
<p data-i18n="Earnings, fares and distance aggregates across all recorded trips">Earnings, fares and distance aggregates across all recorded trips</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="stats-grid">
|
||||
<div class="stat-card">
|
||||
<div class="stat-value" data-kpi="driverEarnings">—</div>
|
||||
<div class="stat-title">Captain earnings (completed trips)</div>
|
||||
<div class="stat-title" data-i18n="Captain earnings (completed trips)">Captain earnings (completed trips)</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-value" data-kpi="avgFare">—</div>
|
||||
<div class="stat-title">Average passenger fare</div>
|
||||
<div class="stat-title" data-i18n="Average passenger fare">Average passenger fare</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-value" data-kpi="totalDistance">—</div>
|
||||
<div class="stat-title">Total distance covered</div>
|
||||
<div class="stat-title" data-i18n="Total distance covered">Total distance covered</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-header"><h3 class="card-title">Trip Performance</h3></div>
|
||||
<div class="card-header"><h3 class="card-title" data-i18n="Trip Performance">Trip Performance</h3></div>
|
||||
<div class="mini-list" id="tripPerformance"></div>
|
||||
</div>
|
||||
</section>
|
||||
@@ -438,8 +443,8 @@
|
||||
<section class="page-view" id="approvalsView">
|
||||
<div class="page-header">
|
||||
<div class="page-title">
|
||||
<h1>Pending Approvals</h1>
|
||||
<p>Admin and service accounts awaiting activation</p>
|
||||
<h1 data-i18n="Pending Approvals">Pending Approvals</h1>
|
||||
<p data-i18n="Admin and service accounts awaiting activation">Admin and service accounts awaiting activation</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -448,12 +453,12 @@
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th data-i18n="ID">ID</th>
|
||||
<th>Name</th>
|
||||
<th>Phone</th>
|
||||
<th>Type</th>
|
||||
<th>Requested role</th>
|
||||
<th>Requested at</th>
|
||||
<th data-i18n="Type">Type</th>
|
||||
<th data-i18n="Requested role">Requested role</th>
|
||||
<th data-i18n="Requested at">Requested at</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -468,22 +473,22 @@
|
||||
<div class="page-header">
|
||||
<div class="page-title">
|
||||
<h1>Complaints</h1>
|
||||
<p>Volume of customer complaints filed</p>
|
||||
<p data-i18n="Volume of customer complaints filed">Volume of customer complaints filed</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="stats-grid">
|
||||
<div class="stat-card">
|
||||
<div class="stat-value" data-kpi="complaintsToday">—</div>
|
||||
<div class="stat-title">Filed today</div>
|
||||
<div class="stat-title" data-i18n="Filed today">Filed today</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-value" data-kpi="complaintsWeek">—</div>
|
||||
<div class="stat-title">This week</div>
|
||||
<div class="stat-title" data-i18n="This week">This week</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-value" data-kpi="complaintsMonth">—</div>
|
||||
<div class="stat-title">This month</div>
|
||||
<div class="stat-title" data-i18n="This month">This month</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
@@ -493,12 +498,12 @@
|
||||
<div class="page-header">
|
||||
<div class="page-title">
|
||||
<h1>Session & Security</h1>
|
||||
<p>Details of the credential currently authorising this console</p>
|
||||
<p data-i18n="Details of the credential currently authorising this console">Details of the credential currently authorising this console</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-header"><h3 class="card-title">Current session</h3></div>
|
||||
<div class="card-header"><h3 class="card-title" data-i18n="Current session">Current session</h3></div>
|
||||
<div class="mini-list" id="sessionInfo"></div>
|
||||
</div>
|
||||
|
||||
@@ -545,7 +550,7 @@
|
||||
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h3 class="card-title">API endpoint</h3>
|
||||
<h3 class="card-title" data-i18n="API endpoint">API endpoint</h3>
|
||||
</div>
|
||||
<p class="card-note">
|
||||
The mobile admin app points at a different host per country. If every panel is empty,
|
||||
@@ -560,7 +565,7 @@
|
||||
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h3 class="card-title">Server diagnostics</h3>
|
||||
<h3 class="card-title" data-i18n="Server diagnostics">Server diagnostics</h3>
|
||||
<div style="display:flex; gap:0.5rem;">
|
||||
<button class="btn btn-secondary btn-sm" id="copyDiagnosticsBtn"><i class="ph ph-copy"></i> Copy</button>
|
||||
<button class="btn btn-secondary btn-sm" id="runDiagnosticsBtn"><i class="ph ph-play"></i> <span>Run diagnostics</span></button>
|
||||
@@ -585,7 +590,7 @@
|
||||
<div class="modal-overlay" id="otpModal">
|
||||
<div class="modal-content" style="max-width: 400px; text-align: center;">
|
||||
<div class="otp-icon"><i class="ph ph-whatsapp-logo"></i></div>
|
||||
<h3 style="color: #fff; margin-bottom: 0.5rem;">WhatsApp Verification</h3>
|
||||
<h3 style="color: #fff; margin-bottom: 0.5rem;" data-i18n="WhatsApp Verification">WhatsApp Verification</h3>
|
||||
<p style="color: var(--text-muted); font-size: 0.88rem; margin-bottom: 1.5rem;" id="otpPhoneText">
|
||||
A 3-digit verification code was sent to your WhatsApp.
|
||||
</p>
|
||||
@@ -595,7 +600,7 @@
|
||||
</div>
|
||||
|
||||
<button type="button" class="btn-primary" id="submitOtpBtn">
|
||||
<i class="ph-bold ph-check"></i> <span>Verify & Sign In</span>
|
||||
<i class="ph-bold ph-check"></i> <span data-i18n="Verify & Sign In">Verify & Sign In</span>
|
||||
</button>
|
||||
|
||||
<button type="button" class="btn btn-secondary" onclick="closeOtpModal()" style="width: 100%; margin-top: 0.75rem; justify-content: center;">
|
||||
@@ -604,6 +609,6 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="js/app.js?v=2026-07-25-2"></script>
|
||||
<script src="js/app.js?v=2026-07-25-6"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
+412
-31
@@ -11,7 +11,199 @@
|
||||
// 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-2';
|
||||
const BUILD = '2026-07-25-6';
|
||||
|
||||
// ── Localisation ─────────────────────────────────────────────────────────
|
||||
// Arabic is the operators' language; English is kept because several screens
|
||||
// mirror field names that only exist in English in the database.
|
||||
const LANG_KEY = 'siro_admin_lang';
|
||||
let lang = localStorage.getItem(LANG_KEY) || 'ar';
|
||||
|
||||
const AR = {
|
||||
// Auth
|
||||
'Operations & Control Console': 'منصة العمليات والتحكم',
|
||||
'Secure session · device-bound token · WhatsApp OTP': 'جلسة آمنة · توكن مرتبط بالجهاز · رمز واتساب',
|
||||
'Admin Email / Phone': 'البريد الإلكتروني أو رقم الهاتف',
|
||||
'Password': 'كلمة المرور',
|
||||
'Sign In': 'تسجيل الدخول',
|
||||
'Verifying…': 'جارٍ التحقق…',
|
||||
'WhatsApp Verification': 'التحقق عبر واتساب',
|
||||
'Verify & Sign In': 'تحقّق وسجّل الدخول',
|
||||
'Cancel': 'إلغاء',
|
||||
'Enter the 3-digit code.': 'أدخل الرمز المكوّن من ٣ أرقام.',
|
||||
// Nav
|
||||
'Operations': 'العمليات',
|
||||
'Overview': 'نظرة عامة',
|
||||
'Rides': 'الرحلات',
|
||||
'Captains': 'الكباتن',
|
||||
'Passengers': 'الركاب',
|
||||
'Business': 'الأعمال',
|
||||
'Financials': 'المالية',
|
||||
'Approvals': 'الموافقات',
|
||||
'Complaints': 'الشكاوى',
|
||||
'Session & Security': 'الجلسة والأمان',
|
||||
'Realtime & Analytics': 'الوقت الحقيقي والتحليلات',
|
||||
'Live Operations': 'العمليات الحيّة',
|
||||
'Growth': 'النمو',
|
||||
'Advanced Analytics': 'التحليلات المتقدمة',
|
||||
'Ride Monitor': 'مراقب الرحلات',
|
||||
'Finance': 'المالية',
|
||||
'Financial V2': 'المالية V2',
|
||||
'Invoices': 'الفواتير',
|
||||
'Growth & Pricing': 'النمو والتسعير',
|
||||
'Marketing Intelligence': 'استخبارات التسويق',
|
||||
'Pricing Engine': 'محرك التسعير',
|
||||
'Tariff Editor': 'محرر التعرفة',
|
||||
'Promo Codes': 'أكواد الخصم',
|
||||
'Campaign Launcher': 'مطلق الحملات',
|
||||
'Demand Heatmap': 'خريطة الطلب',
|
||||
'Quality': 'الجودة',
|
||||
'Fleet Performance': 'أداء الأسطول',
|
||||
'Blacklist & Removal': 'القائمة السوداء والحذف',
|
||||
'Driver Scorecard': 'تقييم الكباتن',
|
||||
'Driver Documents': 'وثائق السائقين',
|
||||
'Transit': 'المواصلات',
|
||||
'Mawasalati Organisations': 'مؤسسات مواصلاتي',
|
||||
'Route Approvals': 'اعتماد المسارات',
|
||||
'Administration': 'الإدارة',
|
||||
'Staff & Employees': 'الكوادر والموظفون',
|
||||
'App Versions': 'إصدارات التطبيق',
|
||||
'Broadcast Notification': 'إشعار جماعي',
|
||||
'Audit Log': 'سجل العمليات',
|
||||
'Error Log': 'سجل الأخطاء',
|
||||
// Chrome
|
||||
'Filter the current table…': 'ابحث في الجدول الحالي…',
|
||||
'Live database': 'قاعدة بيانات حيّة',
|
||||
'Loading live data…': 'جارٍ تحميل البيانات…',
|
||||
'Connecting…': 'جارٍ الاتصال…',
|
||||
'No data from API': 'لا توجد بيانات من الخادم',
|
||||
'Data unavailable': 'البيانات غير متاحة',
|
||||
'Sign Out': 'تسجيل الخروج',
|
||||
'Refresh data': 'تحديث البيانات',
|
||||
'Reload': 'إعادة تحميل',
|
||||
// Common
|
||||
'Loading…': 'جارٍ التحميل…',
|
||||
'Name': 'الاسم',
|
||||
'Phone': 'الهاتف',
|
||||
'Email': 'البريد الإلكتروني',
|
||||
'Status': 'الحالة',
|
||||
'Rating': 'التقييم',
|
||||
'Trips': 'الرحلات',
|
||||
'Cancellations': 'الإلغاءات',
|
||||
'Joined': 'تاريخ الانضمام',
|
||||
'Action': 'إجراء',
|
||||
'Show more': 'عرض المزيد',
|
||||
'Approve': 'اعتماد',
|
||||
'Reject': 'رفض',
|
||||
'Activate': 'تفعيل',
|
||||
'Save': 'حفظ',
|
||||
'Nothing changed on this tariff row.': 'لم يتغيّر شيء في هذا السطر.',
|
||||
// Ride statuses
|
||||
'Completed': 'مكتملة',
|
||||
'In progress': 'جارية',
|
||||
'Waiting': 'بانتظار كابتن',
|
||||
'Cancelled': 'ملغاة',
|
||||
'Cancelled by captain': 'ألغاها الكابتن',
|
||||
'Cancelled by passenger': 'ألغاها الراكب',
|
||||
'Captain assigned': 'تم تعيين كابتن',
|
||||
'Captain arrived': 'وصل الكابتن',
|
||||
'No captain found': 'لم يُعثر على كابتن',
|
||||
'Timed out': 'انتهت المهلة',
|
||||
'Searching for a captain': 'جارٍ البحث عن كابتن',
|
||||
'Pending review': 'قيد المراجعة',
|
||||
'Refused': 'مرفوضة',
|
||||
'Unknown': 'غير معروف',
|
||||
'The server accepted the request but issued no code and no token.': 'قبل الخادم الطلب لكنه لم يُصدر رمزاً ولا توكن.',
|
||||
'last 21 days': 'آخر ٢١ يوماً',
|
||||
'Updated': 'آخر تحديث',
|
||||
'Morning 6–11': 'صباحاً ٦–١١',
|
||||
'Afternoon 12–17': 'ظهراً ١٢–١٧',
|
||||
'Night 18–5': 'ليلاً ١٨–٥',
|
||||
'Comfort': 'كومفورت',
|
||||
'Speed': 'سبيد',
|
||||
'Lady': 'ليدي',
|
||||
'Average trip duration': 'متوسط مدة الرحلة',
|
||||
'Longest trip duration': 'أطول مدة رحلة',
|
||||
'Average distance': 'متوسط المسافة',
|
||||
'Longest distance': 'أطول مسافة',
|
||||
'Cancelled rides': 'الرحلات الملغاة',
|
||||
'Captains with completed trips': 'كباتن أتمّوا رحلات',
|
||||
'Operations Overview': 'نظرة عامة على العمليات',
|
||||
'Live figures read directly from the production database': 'أرقام حيّة مقروءة مباشرة من قاعدة بيانات الإنتاج',
|
||||
'Total Rides': 'إجمالي الرحلات',
|
||||
'Completed Rides': 'الرحلات المكتملة',
|
||||
'Registered Captains': 'الكباتن المسجّلون',
|
||||
'Registered Passengers': 'الركاب المسجّلون',
|
||||
'this month': 'هذا الشهر',
|
||||
'completion rate': 'نسبة الإتمام',
|
||||
'joined this month': 'انضموا هذا الشهر',
|
||||
'Rides per Day': 'الرحلات يومياً',
|
||||
'Ride Outcomes': 'نتائج الرحلات',
|
||||
'Demand by Time of Day': 'الطلب حسب وقت اليوم',
|
||||
'Service Mix': 'توزيع الخدمات',
|
||||
'Trips, assigned captains, fares and live status': 'الرحلات والكباتن المعيّنون والأجور والحالة الحيّة',
|
||||
'Fleet roster, ratings and trip volume': 'قائمة الأسطول والتقييمات وعدد الرحلات',
|
||||
'Most active customer accounts by trip count': 'أكثر حسابات العملاء نشاطاً حسب عدد الرحلات',
|
||||
'Earnings, fares and distance aggregates across all recorded trips': 'الأرباح والأجور والمسافات لكل الرحلات المسجّلة',
|
||||
'Admin and service accounts awaiting activation': 'حسابات المشرفين والخدمة بانتظار التفعيل',
|
||||
'Volume of customer complaints filed': 'عدد الشكاوى المقدَّمة',
|
||||
'Details of the credential currently authorising this console': 'تفاصيل الاعتماد الذي يخوّل هذه المنصة حالياً',
|
||||
'Trip': 'الرحلة',
|
||||
'Passenger': 'الراكب',
|
||||
'Captain': 'الكابتن',
|
||||
'Route': 'المسار',
|
||||
'Fare': 'الأجرة',
|
||||
'Created': 'أُنشئت',
|
||||
'ID': 'المعرّف',
|
||||
'Customer ID': 'معرّف العميل',
|
||||
'Driver ID': 'معرّف السائق',
|
||||
'Requested role': 'الدور المطلوب',
|
||||
'Requested at': 'تاريخ الطلب',
|
||||
'Type': 'النوع',
|
||||
'Filed today': 'قُدّمت اليوم',
|
||||
'This week': 'هذا الأسبوع',
|
||||
'This month': 'هذا الشهر',
|
||||
'Current session': 'الجلسة الحالية',
|
||||
'Server diagnostics': 'تشخيص الخادم',
|
||||
'API endpoint': 'عنوان الـ API',
|
||||
'Compose': 'إنشاء الرسالة',
|
||||
'Preview': 'معاينة',
|
||||
'Pending Approvals': 'الموافقات المعلّقة',
|
||||
'Captain earnings (completed trips)': 'أرباح الكباتن (الرحلات المكتملة)',
|
||||
'Average passenger fare': 'متوسط أجرة الراكب',
|
||||
'Total distance covered': 'إجمالي المسافة المقطوعة',
|
||||
'Trip Performance': 'أداء الرحلات',
|
||||
'Pick a captain above to inspect their documents.': 'اختر كابتناً بالأعلى لفحص وثائقه.',
|
||||
};
|
||||
|
||||
const t = (key) => (lang === 'ar' ? (AR[key] || key) : key);
|
||||
|
||||
function applyLanguage() {
|
||||
const rtl = lang === 'ar';
|
||||
document.documentElement.lang = rtl ? 'ar' : 'en';
|
||||
document.documentElement.dir = rtl ? 'rtl' : 'ltr';
|
||||
|
||||
document.querySelectorAll('[data-i18n]').forEach((node) => {
|
||||
node.textContent = t(node.dataset.i18n);
|
||||
});
|
||||
document.querySelectorAll('[data-i18n-ph]').forEach((node) => {
|
||||
node.placeholder = t(node.dataset.i18nPh);
|
||||
});
|
||||
document.querySelectorAll('[data-i18n-title]').forEach((node) => {
|
||||
node.title = t(node.dataset.i18nTitle);
|
||||
});
|
||||
|
||||
const toggle = $('langToggle');
|
||||
if (toggle) toggle.textContent = rtl ? 'EN' : 'ع';
|
||||
}
|
||||
|
||||
function setLanguage(next) {
|
||||
lang = next;
|
||||
localStorage.setItem(LANG_KEY, next);
|
||||
// Nav labels, module pages and rendered tables all hold translated text,
|
||||
// so the simplest correct refresh is a reload of the rendered views.
|
||||
location.reload();
|
||||
}
|
||||
|
||||
const SESSION_KEY = 'siro_admin_user';
|
||||
const FP_KEY = 'siro_web_fp';
|
||||
@@ -47,6 +239,7 @@
|
||||
|
||||
async function init() {
|
||||
cacheElements();
|
||||
applyLanguage();
|
||||
deviceFingerprint = await resolveFingerprint();
|
||||
if (el.fpPreview) el.fpPreview.textContent = deviceFingerprint.slice(0, 12) + '…';
|
||||
|
||||
@@ -67,7 +260,7 @@
|
||||
|
||||
function cacheElements() {
|
||||
[
|
||||
'authWrapper', 'loginForm', 'loginEmail', 'loginPass', 'loginSubmitBtn', 'fpPreview',
|
||||
'authWrapper', 'loginForm', 'loginEmail', 'loginPass', 'loginSubmitBtn', 'fpPreview', 'loginError',
|
||||
'otpModal', 'otpInput', 'otpPhoneText', 'submitOtpBtn',
|
||||
'sidebar', 'toggleSidebar', 'logoutBtn', 'refreshBtn', 'globalSearch',
|
||||
'connectionPill', 'connectionText', 'lastUpdated',
|
||||
@@ -196,7 +389,7 @@
|
||||
signOut(`Session ended: ${err.message}`);
|
||||
return true;
|
||||
}
|
||||
setConnection('error', 'Data unavailable');
|
||||
setConnection('error', t('Data unavailable'));
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -211,13 +404,28 @@
|
||||
window.closeOtpModal = () => el.otpModal?.classList.remove('active');
|
||||
}
|
||||
|
||||
|
||||
function showLoginError(message, detail) {
|
||||
if (!el.loginError) return;
|
||||
el.loginError.hidden = false;
|
||||
el.loginError.innerHTML = `<i class="ph-fill ph-warning-octagon"></i><div>
|
||||
<strong>${esc(message)}</strong>
|
||||
${detail ? `<span>${esc(detail)}</span>` : ''}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function clearLoginError() {
|
||||
if (el.loginError) el.loginError.hidden = true;
|
||||
}
|
||||
|
||||
async function onLogin(e) {
|
||||
e.preventDefault();
|
||||
const phone = el.loginEmail.value.trim();
|
||||
const password = el.loginPass.value;
|
||||
if (!phone || !password) return;
|
||||
|
||||
busy(el.loginSubmitBtn, true, 'Verifying…');
|
||||
clearLoginError();
|
||||
busy(el.loginSubmitBtn, true, t('Verifying…'));
|
||||
try {
|
||||
const form = new FormData();
|
||||
form.append('phone', phone);
|
||||
@@ -238,12 +446,16 @@
|
||||
// Trusted-device renewal path — no OTP required.
|
||||
establishSession(payload);
|
||||
} else {
|
||||
toast('Unexpected response from the authentication service.', 'danger');
|
||||
showLoginError(
|
||||
t('The server accepted the request but issued no code and no token.'),
|
||||
JSON.stringify(payload).slice(0, 300)
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
showLoginError(err.message, err.status ? `HTTP ${err.status}` : null);
|
||||
toast(err.message, 'danger');
|
||||
} finally {
|
||||
busy(el.loginSubmitBtn, false, 'Sign In');
|
||||
busy(el.loginSubmitBtn, false, t('Sign In'));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -267,9 +479,10 @@
|
||||
el.otpModal.classList.remove('active');
|
||||
establishSession(payload);
|
||||
} catch (err) {
|
||||
showLoginError(err.message, err.status ? `HTTP ${err.status}` : null);
|
||||
toast(err.message, 'danger');
|
||||
} finally {
|
||||
busy(el.submitOtpBtn, false, 'Verify & Sign In');
|
||||
busy(el.submitOtpBtn, false, t('Verify & Sign In'));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -328,7 +541,7 @@
|
||||
|
||||
// ── Data loading ─────────────────────────────────────────────────────────
|
||||
function loadEverything() {
|
||||
setConnection('loading', 'Loading live data…');
|
||||
setConnection('loading', t('Loading live data…'));
|
||||
Promise.allSettled([
|
||||
loadStats(),
|
||||
loadRides(),
|
||||
@@ -340,10 +553,10 @@
|
||||
renderSessionInfo();
|
||||
const ok = results.some((r) => r.status === 'fulfilled');
|
||||
if (ok) {
|
||||
setConnection('live', 'Live database');
|
||||
el.lastUpdated.textContent = 'Updated ' + new Date().toLocaleTimeString();
|
||||
setConnection('live', t('Live database'));
|
||||
el.lastUpdated.textContent = t('Updated') + ' ' + new Date().toLocaleTimeString();
|
||||
} else if (session) {
|
||||
setConnection('error', 'No data from API');
|
||||
setConnection('error', t('No data from API'));
|
||||
el.lastUpdated.textContent = 'No data — check diagnostics';
|
||||
toast('Every endpoint failed. Open “Session & Security” → Run diagnostics.', 'danger');
|
||||
}
|
||||
@@ -454,15 +667,15 @@
|
||||
setKpi('complaintsMonth', fmtInt(stats.countComplaintThisMonth));
|
||||
|
||||
drawDonut('statusDonut', [
|
||||
{ label: 'Completed', value: num(stats.completed_rides), color: '#10b981' },
|
||||
{ label: 'Waiting', value: num(stats.ongoing_rides), color: '#6366f1' },
|
||||
{ label: 'Cancelled', value: num(stats.cancelled_rides), color: '#f43f5e' },
|
||||
{ label: t('Completed'), value: num(stats.completed_rides), color: '#10b981' },
|
||||
{ label: t('Waiting'), value: num(stats.ongoing_rides), color: '#6366f1' },
|
||||
{ label: t('Cancelled'), value: num(stats.cancelled_rides), color: '#f43f5e' },
|
||||
], el.statusLegend);
|
||||
|
||||
drawBarChart('timeOfDayChart', [
|
||||
{ label: 'Morning 6–11', value: num(stats.morning_ride_count) },
|
||||
{ label: 'Afternoon 12–17', value: num(stats.evening_ride_count) },
|
||||
{ label: 'Night 18–5', value: num(stats.night_ride_count) },
|
||||
{ label: t('Morning 6–11'), value: num(stats.morning_ride_count) },
|
||||
{ label: t('Afternoon 12–17'), value: num(stats.evening_ride_count) },
|
||||
{ label: t('Night 18–5'), value: num(stats.night_ride_count) },
|
||||
]);
|
||||
|
||||
renderServiceMix();
|
||||
@@ -471,9 +684,9 @@
|
||||
|
||||
function renderServiceMix() {
|
||||
const types = [
|
||||
{ label: 'Comfort', value: num(stats.comfort), color: '#6366f1' },
|
||||
{ label: 'Speed', value: num(stats.speed), color: '#06b6d4' },
|
||||
{ label: 'Lady', value: num(stats.lady), color: '#8b5cf6' },
|
||||
{ label: t('Comfort'), value: num(stats.comfort), color: '#6366f1' },
|
||||
{ label: t('Speed'), value: num(stats.speed), color: '#06b6d4' },
|
||||
{ label: t('Lady'), value: num(stats.lady), color: '#8b5cf6' },
|
||||
];
|
||||
const total = types.reduce((s, t) => s + t.value, 0) || 1;
|
||||
el.serviceMix.innerHTML = types.map((t) => `
|
||||
@@ -489,12 +702,12 @@
|
||||
|
||||
function renderTripPerformance() {
|
||||
const rows = [
|
||||
['Average trip duration', stats.driver_avg_duration || '—'],
|
||||
['Longest trip duration', stats.longest_duration || '—'],
|
||||
['Average distance', `${fmtNum(stats.average_distance)} km`],
|
||||
['Longest distance', `${fmtNum(stats.longest_distance)} km`],
|
||||
['Cancelled rides', fmtInt(stats.cancelled_rides)],
|
||||
['Captains with completed trips', fmtInt(stats.num_Driver)],
|
||||
[t('Average trip duration'), stats.driver_avg_duration || '—'],
|
||||
[t('Longest trip duration'), stats.longest_duration || '—'],
|
||||
[t('Average distance'), `${fmtNum(stats.average_distance)} km`],
|
||||
[t('Longest distance'), `${fmtNum(stats.longest_distance)} km`],
|
||||
[t('Cancelled rides'), fmtInt(stats.cancelled_rides)],
|
||||
[t('Captains with completed trips'), fmtInt(stats.num_Driver)],
|
||||
];
|
||||
el.tripPerformance.innerHTML = rows.map(([k, v]) => `
|
||||
<div class="kv-row"><span>${k}</span><strong>${v}</strong></div>
|
||||
@@ -970,7 +1183,7 @@
|
||||
groups.forEach((group) => {
|
||||
const label = document.createElement('div');
|
||||
label.className = 'menu-label';
|
||||
label.textContent = group;
|
||||
label.textContent = t(group);
|
||||
menu.appendChild(label);
|
||||
|
||||
MODULES.filter((m) => m.group === group).forEach((mod) => {
|
||||
@@ -982,7 +1195,7 @@
|
||||
item.setAttribute('data-requires-super', '');
|
||||
item.hidden = true;
|
||||
}
|
||||
item.innerHTML = `<i class="ph ${mod.icon}"></i><span>${esc(mod.title)}</span>`;
|
||||
item.innerHTML = `<i class="ph ${mod.icon}"></i><span>${esc(t(mod.title))}</span>`;
|
||||
menu.appendChild(item);
|
||||
|
||||
const section = document.createElement('section');
|
||||
@@ -991,8 +1204,8 @@
|
||||
section.innerHTML = `
|
||||
<div class="page-header">
|
||||
<div class="page-title">
|
||||
<h1>${esc(mod.title)}</h1>
|
||||
<p>${esc(mod.subtitle)}</p>
|
||||
<h1>${esc(t(mod.title))}</h1>
|
||||
<p>${esc(t(mod.subtitle))}</p>
|
||||
</div>
|
||||
<div class="page-actions">
|
||||
<button class="btn btn-secondary btn-sm" data-reload="${mod.id}">
|
||||
@@ -1076,6 +1289,173 @@
|
||||
});
|
||||
}
|
||||
|
||||
// ── 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
|
||||
// retyped, on top of the confirm dialog.
|
||||
async function renderBlacklist(host) {
|
||||
host.innerHTML = `
|
||||
<div class="card notice-card notice-danger">
|
||||
<i class="ph-fill ph-warning-octagon"></i>
|
||||
<span><strong>Removal is permanent.</strong> The account row is deleted and the phone number is
|
||||
added to the blacklist so it cannot register again. There is no undo — only lifting the block,
|
||||
which does not restore the deleted account.</span>
|
||||
</div>
|
||||
|
||||
<div class="card" id="blacklistCurrent"><div class="table-msg">Loading blacklist…</div></div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-header"><h3 class="card-title">Remove & blacklist</h3></div>
|
||||
<div class="tariff-grid">
|
||||
<label class="tariff-field">
|
||||
<span class="tariff-label">Account type</span>
|
||||
<select class="select-input" id="rmType">
|
||||
<option value="passenger">Passenger</option>
|
||||
<option value="driver">Captain</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="tariff-field">
|
||||
<span class="tariff-label">Phone <em>required</em></span>
|
||||
<input type="text" class="form-input" id="rmPhone" autocomplete="off">
|
||||
</label>
|
||||
<label class="tariff-field">
|
||||
<span class="tariff-label">Account ID <em>required for captains</em></span>
|
||||
<input type="text" class="form-input" id="rmId" autocomplete="off">
|
||||
</label>
|
||||
<label class="tariff-field">
|
||||
<span class="tariff-label">Reason</span>
|
||||
<input type="text" class="form-input" id="rmReason" placeholder="Recorded on the blacklist entry">
|
||||
</label>
|
||||
<label class="tariff-field">
|
||||
<span class="tariff-label">Retype the phone to confirm <em>safety check</em></span>
|
||||
<input type="text" class="form-input" id="rmConfirmPhone" autocomplete="off">
|
||||
</label>
|
||||
</div>
|
||||
<div class="api-base-row" style="margin-top:1rem;">
|
||||
<button class="btn btn-danger btn-sm" id="rmSubmit"><i class="ph ph-trash"></i> <span>Delete and blacklist</span></button>
|
||||
<span class="stamp" id="rmStatus"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-header"><h3 class="card-title">Lift a block</h3></div>
|
||||
<p class="card-note">Removes the phone number from the blacklist so it can register again. It does
|
||||
not restore a deleted account.</p>
|
||||
<div class="tariff-grid">
|
||||
<label class="tariff-field">
|
||||
<span class="tariff-label">Account type</span>
|
||||
<select class="select-input" id="ubType">
|
||||
<option value="passenger">Passenger</option>
|
||||
<option value="driver">Captain</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="tariff-field">
|
||||
<span class="tariff-label">Phone</span>
|
||||
<input type="text" class="form-input" id="ubPhone" autocomplete="off">
|
||||
</label>
|
||||
</div>
|
||||
<div class="api-base-row" style="margin-top:1rem;">
|
||||
<button class="btn btn-secondary btn-sm" id="ubSubmit"><i class="ph ph-lock-open"></i> <span>Lift block</span></button>
|
||||
<span class="stamp" id="ubStatus"></span>
|
||||
</div>
|
||||
</div>`;
|
||||
|
||||
$('rmSubmit').addEventListener('click', () => removeAndBlacklist(host));
|
||||
$('ubSubmit').addEventListener('click', () => liftBlock(host));
|
||||
loadBlacklist();
|
||||
}
|
||||
|
||||
async function loadBlacklist() {
|
||||
const panel = $('blacklistCurrent');
|
||||
try {
|
||||
const payload = await api('/Admin/v2/quality/blacklist_manager.php', {
|
||||
params: { action_type: 'get_all' },
|
||||
});
|
||||
panel.innerHTML = '<div class="card-header"><h3 class="card-title">Currently blocked</h3></div><div class="panel-body"></div>';
|
||||
renderPayload(panel.querySelector('.panel-body'), payload);
|
||||
} catch (err) {
|
||||
if (handleApiError(err, 'blacklist')) return;
|
||||
panel.innerHTML = `<div class="card-header"><h3 class="card-title">Currently blocked</h3></div><div class="table-msg is-error">${esc(err.message)}</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
async function removeAndBlacklist(host) {
|
||||
const type = $('rmType').value;
|
||||
const phone = $('rmPhone').value.trim();
|
||||
const id = $('rmId').value.trim();
|
||||
const reason = $('rmReason').value.trim();
|
||||
const confirmPhone = $('rmConfirmPhone').value.trim();
|
||||
const status = $('rmStatus');
|
||||
const label = type === 'driver' ? 'captain' : 'passenger';
|
||||
|
||||
if (!phone) {
|
||||
toast('Enter the phone number of the account to remove.', 'warning');
|
||||
return;
|
||||
}
|
||||
if (confirmPhone !== phone) {
|
||||
toast('The retyped phone does not match — nothing was deleted.', 'danger');
|
||||
return;
|
||||
}
|
||||
if (type === 'driver' && !id) {
|
||||
toast('Removing a captain requires their account ID.', 'warning');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!confirm(
|
||||
`Permanently delete the ${label} on ${phone} and blacklist that number?\n\n` +
|
||||
`${reason ? `Reason: ${reason}\n\n` : ''}` +
|
||||
'The account is deleted from the database. This cannot be undone.'
|
||||
)) return;
|
||||
|
||||
busy($('rmSubmit'), true, 'Deleting…');
|
||||
status.textContent = '';
|
||||
try {
|
||||
if (type === 'driver') {
|
||||
await api('/Admin/driver/deleteCaptain.php', { params: { driver_id: id, phone, reason } });
|
||||
} else {
|
||||
await api('/Admin/passenger/admin_delete_and_blacklist_passenger.php', {
|
||||
params: { id, phone, reason },
|
||||
});
|
||||
}
|
||||
status.textContent = `Removed and blacklisted ${phone}`;
|
||||
toast(`${label === 'captain' ? 'Captain' : 'Passenger'} removed and blacklisted.`, 'success');
|
||||
['rmPhone', 'rmId', 'rmReason', 'rmConfirmPhone'].forEach((f) => { $(f).value = ''; });
|
||||
loadBlacklist();
|
||||
} catch (err) {
|
||||
if (!handleApiError(err, 'remove-account')) toast(err.message, 'danger');
|
||||
} finally {
|
||||
busy($('rmSubmit'), false, 'Delete and blacklist');
|
||||
}
|
||||
}
|
||||
|
||||
async function liftBlock() {
|
||||
const type = $('ubType').value;
|
||||
const phone = $('ubPhone').value.trim();
|
||||
const status = $('ubStatus');
|
||||
|
||||
if (!phone) {
|
||||
toast('Enter the blocked phone number.', 'warning');
|
||||
return;
|
||||
}
|
||||
if (!confirm(`Lift the block on ${phone}? They will be able to register again.`)) return;
|
||||
|
||||
busy($('ubSubmit'), true, 'Working…');
|
||||
try {
|
||||
const path = type === 'driver'
|
||||
? '/Admin/driver/remove_from_blacklist.php'
|
||||
: '/Admin/passenger/admin_unblacklist.php';
|
||||
await api(path, { params: { phone } });
|
||||
status.textContent = `Block lifted for ${phone}`;
|
||||
toast('Block lifted.', 'success');
|
||||
$('ubPhone').value = '';
|
||||
loadBlacklist();
|
||||
} catch (err) {
|
||||
if (!handleApiError(err, 'lift-block')) toast(err.message, 'danger');
|
||||
} finally {
|
||||
busy($('ubSubmit'), false, 'Lift block');
|
||||
}
|
||||
}
|
||||
|
||||
// ── Campaign launcher ────────────────────────────────────────────────────
|
||||
// trigger_campaign.php asks Gemini for a campaign, writes a promo code valid
|
||||
// for seven days, and pushes it to every passenger in the country. The
|
||||
@@ -2444,6 +2824,7 @@
|
||||
|
||||
function setupDataEvents() {
|
||||
el.refreshBtn?.addEventListener('click', () => { if (session) loadEverything(); });
|
||||
$('langToggle')?.addEventListener('click', () => setLanguage(lang === 'ar' ? 'en' : 'ar'));
|
||||
el.rideStatusFilter?.addEventListener('change', () => loadRides().catch(() => {}));
|
||||
|
||||
el.driversPrev?.addEventListener('click', () => {
|
||||
@@ -2565,7 +2946,7 @@
|
||||
|
||||
function labelStatus(status) {
|
||||
if (!status) return 'Unknown';
|
||||
return STATUS_LABELS[String(status).toLowerCase()] || humanize(status);
|
||||
return t(STATUS_LABELS[String(status).toLowerCase()] || humanize(status));
|
||||
}
|
||||
|
||||
function badgeClass(status) {
|
||||
|
||||
Reference in New Issue
Block a user