feat: AutoBooking initial commit — PHP WordPress Plugin (REST API, wpdb, WP_User_Query)

This commit is contained in:
2026-07-03 12:15:26 -04:00
commit c2b493117b
21 changed files with 10438 additions and 0 deletions
+928
View File
@@ -0,0 +1,928 @@
<?php
/**
* Plugin Name: AutoBooking Admin Dashboard
* Description: Panel de control del propietario de la plataforma AutoBooking.
* Gestiona conductores, empresas, viajes, incidentes SOS, zonas y configuracion.
* Version: 1.0.0
* Author: AutoBooking
* Requires at least: 5.9
*/
if ( ! defined( 'ABSPATH' ) ) exit;
class ABAdminDashboard {
const VERSION = '1.0.0';
const NS = 'autobooking/v1';
// API key se lee de wp_options → campo 'ab_gmaps_key' en tab CONFIG
private static $instance = null;
private $plugin_url;
public static function get_instance() {
if ( null === self::$instance ) self::$instance = new self();
return self::$instance;
}
private function __construct() {
$this->plugin_url = plugin_dir_url( __FILE__ );
add_action( 'wp_enqueue_scripts', [ $this, 'enqueue' ] );
add_action( 'rest_api_init', [ $this, 'register_routes' ] );
add_shortcode( 'autobooking_admin', [ $this, 'render' ] );
register_activation_hook( __FILE__, [ $this, 'activate' ] );
}
/* ============================================================
ACTIVACION
============================================================ */
public function activate() {
global $wpdb;
$charset = $wpdb->get_charset_collate();
require_once ABSPATH . 'wp-admin/includes/upgrade.php';
dbDelta( "CREATE TABLE IF NOT EXISTS {$wpdb->prefix}ab_admin_audit (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
admin_user_id BIGINT UNSIGNED NOT NULL,
action VARCHAR(60) NOT NULL DEFAULT '',
target_type VARCHAR(40) NOT NULL DEFAULT '',
target_id BIGINT UNSIGNED NOT NULL DEFAULT 0,
meta LONGTEXT NULL,
created_at DATETIME NOT NULL,
PRIMARY KEY (id),
KEY admin_user_id (admin_user_id),
KEY target (target_type, target_id),
KEY created_at (created_at)
) $charset;" );
dbDelta( "CREATE TABLE IF NOT EXISTS {$wpdb->prefix}ab_fare_config (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
country_code CHAR(2) NOT NULL DEFAULT '',
currency CHAR(3) NOT NULL DEFAULT 'USD',
base_fare DECIMAL(10,2) NOT NULL DEFAULT 3.00,
per_km DECIMAL(10,4) NOT NULL DEFAULT 1.80,
per_minute DECIMAL(10,4) NOT NULL DEFAULT 0.30,
platform_fee_pct DECIMAL(5,4) NOT NULL DEFAULT 0.2000,
minimum_fare DECIMAL(10,2) NOT NULL DEFAULT 5.00,
active TINYINT(1) NOT NULL DEFAULT 1,
updated_at DATETIME NOT NULL,
PRIMARY KEY (id),
UNIQUE KEY country_code (country_code)
) $charset;" );
// Indice en wp_ab_trip_positions (tabla de alto volumen) si no existe
$idx = $wpdb->get_var( "SHOW INDEX FROM {$wpdb->prefix}ab_trip_positions WHERE Key_name = 'trip_ts'" );
if ( ! $idx ) {
$wpdb->query( "ALTER TABLE {$wpdb->prefix}ab_trip_positions ADD INDEX trip_ts (trip_id, ts)" );
}
}
/* ============================================================
PERMISSION
============================================================ */
private function is_admin() {
return is_user_logged_in() && current_user_can( 'manage_autobooking' );
}
private function perm() {
if ( ! $this->is_admin() ) {
return new WP_Error( 'forbidden', 'Acceso denegado.', [ 'status' => 403 ] );
}
return true;
}
private function audit( $action, $target_type, $target_id, $meta = [] ) {
global $wpdb;
$wpdb->insert( "{$wpdb->prefix}ab_admin_audit", [
'admin_user_id' => get_current_user_id(),
'action' => sanitize_text_field( $action ),
'target_type' => sanitize_text_field( $target_type ),
'target_id' => absint( $target_id ),
'meta' => $meta ? wp_json_encode( $meta ) : null,
'created_at' => current_time( 'mysql' ),
] );
}
/* ============================================================
ENQUEUE
============================================================ */
public function enqueue() {
global $post;
if ( ! is_a( $post, 'WP_Post' ) || ! has_shortcode( $post->post_content, 'autobooking_admin' ) ) return;
if ( ! $this->is_admin() ) return;
wp_enqueue_style( 'abad-style', $this->plugin_url . 'assets/admin-dashboard.css', [], self::VERSION );
wp_enqueue_script( 'google-maps-abad',
'https://maps.googleapis.com/maps/api/js?key=' . esc_attr( get_option( 'ab_gmaps_key', '' ) ) . '&libraries=places,drawing',
[], null, true
);
wp_enqueue_script( 'abad-script', $this->plugin_url . 'assets/admin-dashboard.js', [], self::VERSION, true );
$user = wp_get_current_user();
wp_localize_script( 'abad-script', 'AB_ADMIN_CFG', [
'nonce' => wp_create_nonce( 'wp_rest' ),
'api_root' => esc_url_raw( get_rest_url() ),
'gmaps_key' => esc_attr( get_option( 'ab_gmaps_key', '' ) ),
'user_id' => $user->ID,
'user_name' => esc_html( $user->display_name ),
] );
}
/* ============================================================
REST ROUTES
============================================================ */
public function register_routes() {
$ns = self::NS;
$perm = [ $this, 'perm' ];
register_rest_route( $ns, '/admin/overview', [ 'methods' => 'GET', 'callback' => [ $this, 'rest_overview' ], 'permission_callback' => $perm ] );
register_rest_route( $ns, '/admin/drivers', [ 'methods' => 'GET', 'callback' => [ $this, 'rest_drivers' ], 'permission_callback' => $perm ] );
register_rest_route( $ns, '/admin/drivers/pending', [ 'methods' => 'GET', 'callback' => [ $this, 'rest_drivers_pending' ], 'permission_callback' => $perm ] );
register_rest_route( $ns, '/admin/drivers/approve', [ 'methods' => 'POST', 'callback' => [ $this, 'rest_driver_approve' ], 'permission_callback' => $perm ] );
register_rest_route( $ns, '/admin/drivers/reject', [ 'methods' => 'POST', 'callback' => [ $this, 'rest_driver_reject' ], 'permission_callback' => $perm ] );
register_rest_route( $ns, '/admin/drivers/suspend', [ 'methods' => 'POST', 'callback' => [ $this, 'rest_driver_suspend' ], 'permission_callback' => $perm ] );
register_rest_route( $ns, '/admin/companies', [ 'methods' => 'GET', 'callback' => [ $this, 'rest_companies' ], 'permission_callback' => $perm ] );
register_rest_route( $ns, '/admin/companies/activate', [ 'methods' => 'POST', 'callback' => [ $this, 'rest_company_activate' ], 'permission_callback' => $perm ] );
register_rest_route( $ns, '/admin/companies/deactivate',[ 'methods' => 'POST', 'callback' => [ $this, 'rest_company_deactivate' ],'permission_callback' => $perm ] );
register_rest_route( $ns, '/admin/companies/(?P<id>\d+)/invoices', [ 'methods' => 'GET', 'callback' => [ $this, 'rest_company_invoices' ], 'permission_callback' => $perm ] );
register_rest_route( $ns, '/admin/trips', [ 'methods' => 'GET', 'callback' => [ $this, 'rest_trips' ], 'permission_callback' => $perm ] );
register_rest_route( $ns, '/admin/trips/(?P<id>\d+)', [ 'methods' => 'GET', 'callback' => [ $this, 'rest_trip_detail' ], 'permission_callback' => $perm ] );
register_rest_route( $ns, '/admin/trips/export-csv', [ 'methods' => 'GET', 'callback' => [ $this, 'rest_trips_csv' ], 'permission_callback' => $perm ] );
register_rest_route( $ns, '/admin/incidents', [ 'methods' => 'GET', 'callback' => [ $this, 'rest_incidents' ], 'permission_callback' => $perm ] );
register_rest_route( $ns, '/admin/incidents/(?P<id>\d+)/resolve', [ 'methods' => 'POST', 'callback' => [ $this, 'rest_incident_resolve' ], 'permission_callback' => $perm ] );
register_rest_route( $ns, '/admin/zones', [ 'methods' => 'GET', 'callback' => [ $this, 'rest_zones' ], 'permission_callback' => $perm ] );
register_rest_route( $ns, '/admin/zones/save', [ 'methods' => 'POST', 'callback' => [ $this, 'rest_zone_save' ], 'permission_callback' => $perm ] );
register_rest_route( $ns, '/admin/zones/(?P<id>\d+)/deactivate', [ 'methods' => 'POST', 'callback' => [ $this, 'rest_zone_deactivate' ], 'permission_callback' => $perm ] );
register_rest_route( $ns, '/admin/settings', [ 'methods' => 'GET', 'callback' => [ $this, 'rest_settings_get' ], 'permission_callback' => $perm ] );
register_rest_route( $ns, '/admin/settings/save', [ 'methods' => 'POST', 'callback' => [ $this, 'rest_settings_save' ], 'permission_callback' => $perm ] );
register_rest_route( $ns, '/admin/fare-config', [ 'methods' => 'GET', 'callback' => [ $this, 'rest_fare_get' ], 'permission_callback' => $perm ] );
register_rest_route( $ns, '/admin/fare-config/save', [ 'methods' => 'POST', 'callback' => [ $this, 'rest_fare_save' ], 'permission_callback' => $perm ] );
}
/* ============================================================
OVERVIEW
============================================================ */
public function rest_overview( $req ) {
global $wpdb;
$today_start = date( 'Y-m-d 00:00:00' );
$today_end = date( 'Y-m-d 23:59:59' );
$week_start = date( 'Y-m-d 00:00:00', strtotime( '-6 days' ) );
$trips_today = (int) $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) FROM {$wpdb->prefix}ab_trips WHERE created_at BETWEEN %s AND %s", $today_start, $today_end ) );
$trips_week = (int) $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) FROM {$wpdb->prefix}ab_trips WHERE created_at >= %s", $week_start ) );
$active_trips = (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$wpdb->prefix}ab_trips WHERE status IN ('assigned','en_route','waiting','in_progress')" );
$drivers_online = (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$wpdb->prefix}ab_driver_status WHERE online = 1" );
$revenue_today = (float) $wpdb->get_var( $wpdb->prepare( "SELECT COALESCE(SUM(fare_total_amount),0) FROM {$wpdb->prefix}ab_trips WHERE status='finished' AND created_at BETWEEN %s AND %s", $today_start, $today_end ) );
$sos_open = (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$wpdb->prefix}ab_incidents WHERE status='active'" );
$pending_drivers= (int) ( new WP_User_Query( [ 'role' => 'driver_pending', 'count_total' => true, 'number' => 0 ] ) )->get_total();
$chart = $wpdb->get_results(
"SELECT DATE(created_at) AS the_date, COUNT(*) AS total, COALESCE(SUM(fare_total_amount),0) AS revenue
FROM {$wpdb->prefix}ab_trips
WHERE created_at >= NOW() - INTERVAL 30 DAY
GROUP BY DATE(created_at) ORDER BY the_date ASC",
ARRAY_A
);
return rest_ensure_response( compact( 'trips_today','trips_week','active_trips','drivers_online','revenue_today','sos_open','pending_drivers' ) + [ 'chart' => $chart ?: [] ] );
}
/* ============================================================
DRIVERS
============================================================ */
public function rest_drivers( $req ) {
$search = sanitize_text_field( $req->get_param( 'search' ) ?: '' );
$filter = sanitize_text_field( $req->get_param( 'filter' ) ?: '' );
$page = max( 1, (int) ( $req->get_param( 'page' ) ?: 1 ) );
$per = 20;
$args = [ 'role' => 'driver', 'number' => $per, 'offset' => ( $page - 1 ) * $per, 'orderby' => 'display_name', 'order' => 'ASC' ];
if ( $search ) { $args['search'] = '*' . $search . '*'; $args['search_columns'] = [ 'display_name', 'user_email' ]; }
$query = new WP_User_Query( $args );
$total = $query->get_total();
$drivers = [];
global $wpdb;
foreach ( $query->get_results() as $user ) {
$uid = $user->ID;
$status = $wpdb->get_row( $wpdb->prepare( "SELECT online, last_seen, last_lat, last_lng FROM {$wpdb->prefix}ab_driver_status WHERE driver_id = %d", $uid ) );
if ( $filter === 'online' && ( ! $status || ! $status->online ) ) continue;
if ( $filter === 'offline' && $status && $status->online ) continue;
$rating = (float) $wpdb->get_var( $wpdb->prepare( "SELECT AVG(driver_rating) FROM {$wpdb->prefix}ab_trips WHERE driver_id = %d AND driver_rating > 0", $uid ) );
$trips_total = (int) $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) FROM {$wpdb->prefix}ab_trips WHERE driver_id = %d AND status='finished'", $uid ) );
$drivers[] = [
'id' => $uid,
'name' => $user->display_name,
'email' => $user->user_email,
'phone' => get_user_meta( $uid, 'phone', true ),
'vehicle_type' => get_user_meta( $uid, 'vehicle_type', true ),
'vehicle_plate' => get_user_meta( $uid, 'vehicle_plate', true ) ?: get_user_meta( $uid, 'license_plate', true ),
'photo_url' => get_user_meta( $uid, 'photo_url', true ),
'online' => $status ? (bool) $status->online : false,
'last_seen' => $status ? $status->last_seen : null,
'avg_rating' => round( $rating, 2 ),
'trips_total' => $trips_total,
'registered' => $user->user_registered,
];
}
return rest_ensure_response( compact( 'drivers', 'total', 'page' ) + [ 'per_page' => $per ] );
}
public function rest_drivers_pending( $req ) {
$query = new WP_User_Query( [ 'role' => 'driver_pending', 'number' => 100, 'orderby' => 'registered', 'order' => 'ASC' ] );
$list = [];
foreach ( $query->get_results() as $user ) {
$uid = $user->ID;
$list[] = [
'id' => $uid,
'name' => $user->display_name,
'email' => $user->user_email,
'phone' => get_user_meta( $uid, 'phone', true ),
'vehicle_type' => get_user_meta( $uid, 'vehicle_type', true ),
'vehicle_plate' => get_user_meta( $uid, 'vehicle_plate', true ) ?: get_user_meta( $uid, 'license_plate', true ),
'license_expiry' => get_user_meta( $uid, 'license_expiry', true ),
'insurance_expiry' => get_user_meta( $uid, 'insurance_expiry', true ),
'photo_url' => get_user_meta( $uid, 'photo_url', true ),
'registered' => $user->user_registered,
// TODO: documentos (wp_ab_driver_documents) pendiente de crear - ver CHANGES.md
];
}
return rest_ensure_response( [ 'drivers' => $list, 'total' => count( $list ) ] );
}
public function rest_driver_approve( $req ) {
$uid = absint( $req->get_param( 'user_id' ) );
$user = $uid ? get_userdata( $uid ) : null;
if ( ! $user ) return new WP_Error( 'not_found', 'Usuario no encontrado.', [ 'status' => 404 ] );
if ( ! in_array( 'driver_pending', (array) $user->roles, true ) ) {
return new WP_Error( 'bad_state', 'El usuario no es driver_pending.', [ 'status' => 400 ] );
}
$user->remove_role( 'driver_pending' );
$user->add_role( 'driver' );
$this->audit( 'driver_approve', 'user', $uid );
return rest_ensure_response( [ 'ok' => true, 'msg' => 'Conductor aprobado.' ] );
}
public function rest_driver_reject( $req ) {
$uid = absint( $req->get_param( 'user_id' ) );
$reason = sanitize_textarea_field( $req->get_param( 'reason' ) ?: '' );
$user = $uid ? get_userdata( $uid ) : null;
if ( ! $user ) return new WP_Error( 'not_found', 'Usuario no encontrado.', [ 'status' => 404 ] );
$user->set_role( 'subscriber' );
if ( $reason ) update_user_meta( $uid, 'ab_rejection_reason', $reason );
$this->audit( 'driver_reject', 'user', $uid, [ 'reason' => $reason ] );
return rest_ensure_response( [ 'ok' => true, 'msg' => 'Conductor rechazado.' ] );
}
public function rest_driver_suspend( $req ) {
global $wpdb;
$uid = absint( $req->get_param( 'user_id' ) );
$reason = sanitize_textarea_field( $req->get_param( 'reason' ) ?: '' );
$user = $uid ? get_userdata( $uid ) : null;
if ( ! $user ) return new WP_Error( 'not_found', 'Usuario no encontrado.', [ 'status' => 404 ] );
$user->set_role( 'subscriber' );
update_user_meta( $uid, 'ab_suspended', 1 );
if ( $reason ) update_user_meta( $uid, 'ab_suspension_reason', $reason );
$wpdb->update( "{$wpdb->prefix}ab_driver_status", [ 'online' => 0 ], [ 'driver_id' => $uid ] );
$this->audit( 'driver_suspend', 'user', $uid, [ 'reason' => $reason ] );
return rest_ensure_response( [ 'ok' => true, 'msg' => 'Conductor suspendido.' ] );
}
/* ============================================================
COMPANIES
============================================================ */
public function rest_companies( $req ) {
global $wpdb;
$search = sanitize_text_field( $req->get_param( 'search' ) ?: '' );
$page = max( 1, (int) ( $req->get_param( 'page' ) ?: 1 ) );
$per = 20;
$where = '1=1';
if ( $search ) $where .= $wpdb->prepare( ' AND (name LIKE %s OR email LIKE %s)', '%' . $wpdb->esc_like( $search ) . '%', '%' . $wpdb->esc_like( $search ) . '%' );
$total = (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$wpdb->prefix}ab_companies WHERE $where" );
$rows = $wpdb->get_results( $wpdb->prepare( "SELECT * FROM {$wpdb->prefix}ab_companies WHERE $where ORDER BY created_at DESC LIMIT %d OFFSET %d", $per, ( $page - 1 ) * $per ), ARRAY_A );
foreach ( $rows as &$row ) {
$cid = (int) $row['id'];
$row['trips_count'] = (int) $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) FROM {$wpdb->prefix}ab_trips WHERE company_id = %d", $cid ) );
$wp_uid = $wpdb->get_var( $wpdb->prepare( "SELECT user_id FROM {$wpdb->usermeta} WHERE meta_key='ab_company_id' AND meta_value=%s LIMIT 1", $cid ) );
$row['wp_user_id'] = $wp_uid ? (int) $wp_uid : null;
}
unset( $row );
return rest_ensure_response( [ 'companies' => $rows ?: [], 'total' => $total, 'page' => $page, 'per_page' => $per ] );
}
public function rest_company_activate( $req ) {
global $wpdb;
$cid = absint( $req->get_param( 'company_id' ) );
if ( ! $cid ) return new WP_Error( 'bad_request', 'company_id requerido.', [ 'status' => 400 ] );
$wpdb->update( "{$wpdb->prefix}ab_companies", [ 'active' => 1 ], [ 'id' => $cid ] );
$user_ids = $wpdb->get_col( $wpdb->prepare( "SELECT user_id FROM {$wpdb->usermeta} WHERE meta_key='ab_company_id' AND meta_value=%s", $cid ) );
foreach ( $user_ids as $uid ) {
$u = get_userdata( (int) $uid );
if ( $u && ! in_array( 'corporate_admin', (array) $u->roles, true ) ) $u->add_role( 'corporate_admin' );
}
$this->audit( 'company_activate', 'company', $cid );
return rest_ensure_response( [ 'ok' => true, 'msg' => 'Empresa activada.' ] );
}
public function rest_company_deactivate( $req ) {
global $wpdb;
$cid = absint( $req->get_param( 'company_id' ) );
if ( ! $cid ) return new WP_Error( 'bad_request', 'company_id requerido.', [ 'status' => 400 ] );
$wpdb->update( "{$wpdb->prefix}ab_companies", [ 'active' => 0 ], [ 'id' => $cid ] );
$user_ids = $wpdb->get_col( $wpdb->prepare( "SELECT user_id FROM {$wpdb->usermeta} WHERE meta_key='ab_company_id' AND meta_value=%s", $cid ) );
foreach ( $user_ids as $uid ) { $u = get_userdata( (int) $uid ); if ( $u ) $u->remove_role( 'corporate_admin' ); }
$this->audit( 'company_deactivate', 'company', $cid );
return rest_ensure_response( [ 'ok' => true, 'msg' => 'Empresa desactivada.' ] );
}
public function rest_company_invoices( $req ) {
global $wpdb;
$cid = absint( $req['id'] );
$rows = $wpdb->get_results( $wpdb->prepare( "SELECT * FROM {$wpdb->prefix}ab_invoices WHERE company_id = %d ORDER BY created_at DESC LIMIT 24", $cid ), ARRAY_A );
return rest_ensure_response( [ 'invoices' => $rows ?: [] ] );
}
/* ============================================================
TRIPS
============================================================ */
public function rest_trips( $req ) {
global $wpdb;
$page = max( 1, (int) ( $req->get_param( 'page' ) ?: 1 ) );
$per = 25;
$status = sanitize_text_field( $req->get_param( 'status' ) ?: '' );
$driver_id = absint( $req->get_param( 'driver_id' ) ?: 0 );
$date_from = sanitize_text_field( $req->get_param( 'date_from' ) ?: '' );
$date_to = sanitize_text_field( $req->get_param( 'date_to' ) ?: '' );
$search = sanitize_text_field( $req->get_param( 'search' ) ?: '' );
$where = '1=1';
if ( $status ) $where .= $wpdb->prepare( ' AND status = %s', $status );
if ( $driver_id ) $where .= $wpdb->prepare( ' AND driver_id = %d', $driver_id );
if ( $date_from ) $where .= $wpdb->prepare( ' AND DATE(created_at) >= %s', $date_from );
if ( $date_to ) $where .= $wpdb->prepare( ' AND DATE(created_at) <= %s', $date_to );
if ( $search ) $where .= $wpdb->prepare( ' AND (passenger_name LIKE %s OR driver_name LIKE %s OR trip_uuid LIKE %s)',
'%' . $wpdb->esc_like( $search ) . '%', '%' . $wpdb->esc_like( $search ) . '%', '%' . $wpdb->esc_like( $search ) . '%'
);
$total = (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$wpdb->prefix}ab_trips WHERE $where" );
$rows = $wpdb->get_results( $wpdb->prepare(
"SELECT id, trip_uuid, status, driver_id, driver_name, passenger_name,
fare_total_amount, platform_fee_amount, driver_payout_amount,
currency, driver_rating, created_at, updated_at
FROM {$wpdb->prefix}ab_trips WHERE $where ORDER BY created_at DESC LIMIT %d OFFSET %d",
$per, ( $page - 1 ) * $per
), ARRAY_A );
return rest_ensure_response( [ 'trips' => $rows ?: [], 'total' => $total, 'page' => $page, 'per_page' => $per ] );
}
public function rest_trip_detail( $req ) {
global $wpdb;
$id = absint( $req['id'] );
$trip = $wpdb->get_row( $wpdb->prepare( "SELECT * FROM {$wpdb->prefix}ab_trips WHERE id = %d", $id ), ARRAY_A );
if ( ! $trip ) return new WP_Error( 'not_found', 'Viaje no encontrado.', [ 'status' => 404 ] );
// Posiciones acotadas a 500 para rendimiento (tabla de alto volumen)
$positions = $wpdb->get_results( $wpdb->prepare(
"SELECT lat, lng, ts FROM {$wpdb->prefix}ab_trip_positions WHERE trip_id = %d ORDER BY ts ASC LIMIT 500",
$id
), ARRAY_A );
$chat = $wpdb->get_results( $wpdb->prepare(
"SELECT sender, message, created_at FROM {$wpdb->prefix}autobooking_chat WHERE ride_id = %d ORDER BY created_at ASC LIMIT 200",
$id
), ARRAY_A );
return rest_ensure_response( [ 'trip' => $trip, 'positions' => $positions ?: [], 'chat' => $chat ?: [] ] );
}
public function rest_trips_csv( $req ) {
global $wpdb;
$date_from = sanitize_text_field( $req->get_param( 'date_from' ) ?: '' );
$date_to = sanitize_text_field( $req->get_param( 'date_to' ) ?: '' );
$status = sanitize_text_field( $req->get_param( 'status' ) ?: '' );
$where = '1=1';
if ( $status ) $where .= $wpdb->prepare( ' AND status = %s', $status );
if ( $date_from ) $where .= $wpdb->prepare( ' AND DATE(created_at) >= %s', $date_from );
if ( $date_to ) $where .= $wpdb->prepare( ' AND DATE(created_at) <= %s', $date_to );
$rows = $wpdb->get_results(
"SELECT id, trip_uuid, created_at, driver_name, passenger_name, fare_total_amount, platform_fee_amount, driver_payout_amount, currency, status, driver_rating
FROM {$wpdb->prefix}ab_trips WHERE $where ORDER BY created_at DESC LIMIT 10000",
ARRAY_A
);
$csv = '"ID","UUID","Fecha","Conductor","Pasajero","Total","Comision","Neto","Moneda","Estado","Rating"' . "\n";
foreach ( $rows as $r ) {
$csv .= implode( ',', array_map( fn($c) => '"' . str_replace( '"', '""', (string) $c ) . '"', array_values( $r ) ) ) . "\n";
}
return rest_ensure_response( [ 'csv' => $csv, 'filename' => 'viajes-' . date( 'Y-m-d' ) . '.csv' ] );
}
/* ============================================================
INCIDENTS
============================================================ */
public function rest_incidents( $req ) {
global $wpdb;
$filter = sanitize_text_field( $req->get_param( 'filter' ) ?: 'active' );
$page = max( 1, (int) ( $req->get_param( 'page' ) ?: 1 ) );
$per = 20;
$where = '1=1';
if ( $filter && $filter !== 'all' ) $where .= $wpdb->prepare( ' AND i.status = %s', $filter );
$total = (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$wpdb->prefix}ab_incidents i WHERE $where" );
$rows = $wpdb->get_results( $wpdb->prepare(
"SELECT i.*, u.display_name AS driver_name
FROM {$wpdb->prefix}ab_incidents i
LEFT JOIN {$wpdb->users} u ON u.ID = i.driver_id
WHERE $where ORDER BY (i.status = 'active') DESC, i.incident_at DESC
LIMIT %d OFFSET %d",
$per, ( $page - 1 ) * $per
), ARRAY_A );
$upload_dir = wp_upload_dir();
foreach ( $rows as &$row ) {
$dir = $upload_dir['basedir'] . '/ab-incidents/' . $row['id'];
$row['has_audio'] = is_dir( $dir ) && ! empty( glob( $dir . '/*.webm' ) );
}
unset( $row );
return rest_ensure_response( [ 'incidents' => $rows ?: [], 'total' => $total, 'page' => $page ] );
}
public function rest_incident_resolve( $req ) {
global $wpdb;
$id = absint( $req['id'] );
$note = sanitize_textarea_field( $req->get_param( 'note' ) ?: '' );
$status = in_array( $req->get_param( 'status' ), [ 'resolved', 'attended' ], true ) ? $req->get_param( 'status' ) : 'resolved';
$inc = $wpdb->get_var( $wpdb->prepare( "SELECT id FROM {$wpdb->prefix}ab_incidents WHERE id = %d", $id ) );
if ( ! $inc ) return new WP_Error( 'not_found', 'Incidente no encontrado.', [ 'status' => 404 ] );
$wpdb->update( "{$wpdb->prefix}ab_incidents", [ 'status' => $status, 'resolved_at' => current_time( 'mysql' ), 'notes' => $note ], [ 'id' => $id ] );
$this->audit( 'incident_' . $status, 'incident', $id, [ 'note' => $note ] );
return rest_ensure_response( [ 'ok' => true ] );
}
/* ============================================================
ZONES
============================================================ */
public function rest_zones( $req ) {
global $wpdb;
return rest_ensure_response( $wpdb->get_results( "SELECT * FROM {$wpdb->prefix}ab_zone_alerts ORDER BY active DESC, created_at DESC", ARRAY_A ) ?: [] );
}
public function rest_zone_save( $req ) {
global $wpdb;
$body = $req->get_json_params();
$id = absint( $body['id'] ?? 0 );
$title = sanitize_text_field( $body['title'] ?? '' );
$lat = (float) ( $body['lat'] ?? 0 );
$lng = (float) ( $body['lng'] ?? 0 );
$type = in_array( $body['type'] ?? '', [ 'hotspot', 'bonus', 'warning' ], true ) ? $body['type'] : 'hotspot';
if ( ! $title || ! $lat || ! $lng ) return new WP_Error( 'missing', 'Titulo, lat y lng son requeridos.', [ 'status' => 400 ] );
$data = [
'title' => $title,
'description' => sanitize_textarea_field( $body['description'] ?? '' ),
'lat' => $lat,
'lng' => $lng,
'radius_km' => max( 0.1, (float) ( $body['radius_km'] ?? 2 ) ),
'bonus_amount' => max( 0, (float) ( $body['bonus_amount'] ?? 0 ) ),
'bonus_currency' => sanitize_text_field( $body['bonus_currency'] ?? 'USD' ),
'countdown_seconds' => max( 60, (int) ( $body['countdown_seconds'] ?? 180 ) ),
'type' => $type,
'active' => 1,
'expires_at' => sanitize_text_field( $body['expires_at'] ?? '' ) ?: null,
];
if ( $id ) {
$wpdb->update( "{$wpdb->prefix}ab_zone_alerts", $data, [ 'id' => $id ] );
$this->audit( 'zone_update', 'zone', $id );
} else {
$data['created_at'] = current_time( 'mysql' );
$wpdb->insert( "{$wpdb->prefix}ab_zone_alerts", $data );
$id = $wpdb->insert_id;
$this->audit( 'zone_create', 'zone', $id );
}
return rest_ensure_response( [ 'ok' => true, 'id' => $id ] );
}
public function rest_zone_deactivate( $req ) {
global $wpdb;
$id = absint( $req['id'] );
$wpdb->update( "{$wpdb->prefix}ab_zone_alerts", [ 'active' => 0 ], [ 'id' => $id ] );
$this->audit( 'zone_deactivate', 'zone', $id );
return rest_ensure_response( [ 'ok' => true ] );
}
/* ============================================================
SETTINGS & FARE CONFIG
============================================================ */
public function rest_settings_get( $req ) {
return rest_ensure_response( [
'platform_name' => get_option( 'ab_platform_name', 'AutoBooking' ),
'platform_logo' => get_option( 'ab_platform_logo', '' ),
'sos_email' => get_option( 'ab_sos_email', get_option( 'admin_email' ) ),
'gmaps_key' => get_option( 'ab_gmaps_key', '' ),
'platform_fee_pct' => (float) get_option( 'ab_platform_fee_pct', 0.20 ),
] );
}
public function rest_settings_save( $req ) {
$body = $req->get_json_params();
if ( isset( $body['platform_name'] ) ) update_option( 'ab_platform_name', sanitize_text_field( $body['platform_name'] ) );
if ( isset( $body['platform_logo'] ) ) update_option( 'ab_platform_logo', esc_url_raw( $body['platform_logo'] ) );
if ( isset( $body['sos_email'] ) ) update_option( 'ab_sos_email', sanitize_email( $body['sos_email'] ) );
if ( isset( $body['gmaps_key'] ) ) update_option( 'ab_gmaps_key', sanitize_text_field( $body['gmaps_key'] ) );
if ( isset( $body['platform_fee_pct'] ) ) update_option( 'ab_platform_fee_pct', max( 0, min( 1, (float) $body['platform_fee_pct'] ) ) );
$this->audit( 'settings_save', 'settings', 0 );
return rest_ensure_response( [ 'ok' => true ] );
}
public function rest_fare_get( $req ) {
global $wpdb;
return rest_ensure_response( $wpdb->get_results( "SELECT * FROM {$wpdb->prefix}ab_fare_config ORDER BY country_code ASC", ARRAY_A ) ?: [] );
}
public function rest_fare_save( $req ) {
global $wpdb;
$body = $req->get_json_params();
$country_code = strtoupper( sanitize_text_field( $body['country_code'] ?? '' ) );
if ( strlen( $country_code ) !== 2 ) return new WP_Error( 'bad_request', 'country_code debe ser 2 letras ISO 3166.', [ 'status' => 400 ] );
$data = [
'country_code' => $country_code,
'currency' => strtoupper( sanitize_text_field( $body['currency'] ?? 'USD' ) ),
'base_fare' => max( 0, (float) ( $body['base_fare'] ?? 3 ) ),
'per_km' => max( 0, (float) ( $body['per_km'] ?? 1.80 ) ),
'per_minute' => max( 0, (float) ( $body['per_minute'] ?? 0.30 ) ),
'platform_fee_pct' => max( 0, min( 1, (float) ( $body['platform_fee_pct'] ?? 0.20 ) ) ),
'minimum_fare' => max( 0, (float) ( $body['minimum_fare'] ?? 5 ) ),
'active' => 1,
'updated_at' => current_time( 'mysql' ),
];
$existing = $wpdb->get_var( $wpdb->prepare( "SELECT id FROM {$wpdb->prefix}ab_fare_config WHERE country_code = %s", $country_code ) );
if ( $existing ) $wpdb->update( "{$wpdb->prefix}ab_fare_config", $data, [ 'country_code' => $country_code ] );
else $wpdb->insert( "{$wpdb->prefix}ab_fare_config", $data );
$this->audit( 'fare_save', 'fare_config', 0, [ 'country' => $country_code ] );
return rest_ensure_response( [ 'ok' => true ] );
}
/* ============================================================
SHORTCODE RENDER
============================================================ */
public function render( $atts ) {
if ( ! is_user_logged_in() ) { wp_redirect( wp_login_url( get_permalink() ) ); exit; }
if ( ! current_user_can( 'manage_autobooking' ) ) {
return '<div style="background:#0b0b0b;color:#fff;padding:40px;text-align:center;font-family:system-ui;">
<p style="font-size:18px;font-weight:700;color:#FF6F00;">Acceso Restringido</p>
<p style="color:rgba(255,255,255,.6);">Esta seccion es exclusiva para administradores de la plataforma.</p>
<a href="' . esc_url( wp_logout_url( home_url() ) ) . '" style="color:#FF6F00;">Cerrar sesion</a>
</div>';
}
$user = wp_get_current_user();
$logo = get_option( 'ab_platform_logo', '' );
$plat_name = esc_html( get_option( 'ab_platform_name', 'AutoBooking' ) );
ob_start();
?>
<div id="abad-app">
<header class="abad-header">
<div class="abad-header__brand">
<?php if ( $logo ) : ?>
<img src="<?php echo esc_url( $logo ); ?>" alt="logo" class="abad-header__logo">
<?php else : ?>
<div class="abad-header__icon">
<svg width="28" height="28" viewBox="0 0 24 24" fill="none"><path d="M12 2L2 7l10 5 10-5-10-5z" stroke="#FF6F00" stroke-width="2" stroke-linejoin="round"/><path d="M2 17l10 5 10-5M2 12l10 5 10-5" stroke="#FF6F00" stroke-width="2" stroke-linejoin="round"/></svg>
</div>
<?php endif; ?>
<div class="abad-header__wordmark">
<span class="abad-header__name"><?php echo $plat_name; ?></span>
<span class="abad-header__role">Admin Panel</span>
</div>
</div>
<div class="abad-header__right">
<div class="abad-sos-indicator" id="abad-sos-indicator" style="display:none">
<svg width="15" height="15" viewBox="0 0 24 24" fill="none"><path d="M12 9v4M12 17h.01M10.29 3.86L1.82 18a2 2 0 001.71 3h16.94a2 2 0 001.71-3L13.71 3.86a2 2 0 00-3.42 0z" stroke="#ef4444" stroke-width="2" stroke-linecap="round"/></svg>
<span id="abad-sos-count">0</span> SOS
</div>
<button class="abad-lang-btn" id="abad-lang-btn">ES</button>
<span class="abad-header__user"><?php echo esc_html( $user->display_name ); ?></span>
</div>
</header>
<nav class="abad-tabs" role="tablist">
<button class="abad-tab abad-tab--active" data-tab="overview" role="tab">
<svg width="17" height="17" viewBox="0 0 24 24" fill="none"><rect x="3" y="3" width="7" height="7" rx="1" stroke="currentColor" stroke-width="2"/><rect x="14" y="3" width="7" height="7" rx="1" stroke="currentColor" stroke-width="2"/><rect x="3" y="14" width="7" height="7" rx="1" stroke="currentColor" stroke-width="2"/><rect x="14" y="14" width="7" height="7" rx="1" stroke="currentColor" stroke-width="2"/></svg>
<span data-es="RESUMEN" data-en="OVERVIEW">RESUMEN</span>
</button>
<button class="abad-tab" data-tab="drivers" role="tab">
<svg width="17" height="17" viewBox="0 0 24 24" fill="none"><circle cx="12" cy="8" r="4" stroke="currentColor" stroke-width="2"/><path d="M4 20c0-4 3.6-7 8-7s8 3 8 7" stroke="currentColor" stroke-width="2" stroke-linecap="round"/></svg>
<span data-es="CONDUCTORES" data-en="DRIVERS">CONDUCTORES</span>
<span class="abad-badge" id="tab-pending-badge" style="display:none"></span>
</button>
<button class="abad-tab" data-tab="companies" role="tab">
<svg width="17" height="17" viewBox="0 0 24 24" fill="none"><path d="M3 21h18M5 21V7l7-4 7 4v14M9 21v-4h6v4M9 11h.01M15 11h.01M12 11h.01" stroke="currentColor" stroke-width="2" stroke-linecap="round"/></svg>
<span data-es="EMPRESAS" data-en="COMPANIES">EMPRESAS</span>
</button>
<button class="abad-tab" data-tab="trips" role="tab">
<svg width="17" height="17" viewBox="0 0 24 24" fill="none"><path d="M5 17H3a2 2 0 01-2-2V5a2 2 0 012-2h11a2 2 0 012 2v3M9 17h8a2 2 0 002-2v-5a2 2 0 00-2-2H9a2 2 0 00-2 2v5a2 2 0 002 2z" stroke="currentColor" stroke-width="2" stroke-linecap="round"/><circle cx="7.5" cy="17.5" r="2.5" stroke="currentColor" stroke-width="2"/><circle cx="17.5" cy="17.5" r="2.5" stroke="currentColor" stroke-width="2"/></svg>
<span data-es="VIAJES" data-en="TRIPS">VIAJES</span>
</button>
<button class="abad-tab" data-tab="incidents" role="tab">
<svg width="17" height="17" viewBox="0 0 24 24" fill="none"><path d="M12 9v4M12 17h.01M10.29 3.86L1.82 18a2 2 0 001.71 3h16.94a2 2 0 001.71-3L13.71 3.86a2 2 0 00-3.42 0z" stroke="currentColor" stroke-width="2" stroke-linecap="round"/></svg>
<span data-es="INCIDENTES" data-en="INCIDENTS">INCIDENTES</span>
<span class="abad-badge abad-badge--red" id="tab-sos-badge" style="display:none"></span>
</button>
<button class="abad-tab" data-tab="zones" role="tab">
<svg width="17" height="17" viewBox="0 0 24 24" fill="none"><circle cx="12" cy="12" r="3" stroke="currentColor" stroke-width="2"/><path d="M12 2v3M12 19v3M2 12h3M19 12h3" stroke="currentColor" stroke-width="2" stroke-linecap="round"/><circle cx="12" cy="12" r="9" stroke="currentColor" stroke-width="2" stroke-dasharray="4 2"/></svg>
<span data-es="ZONAS" data-en="ZONES">ZONAS</span>
</button>
<button class="abad-tab" data-tab="settings" role="tab">
<svg width="17" height="17" viewBox="0 0 24 24" fill="none"><circle cx="12" cy="12" r="3" stroke="currentColor" stroke-width="2"/><path d="M19.4 15a1.65 1.65 0 00.33 1.82l.06.06a2 2 0 010 2.83 2 2 0 01-2.83 0l-.06-.06a1.65 1.65 0 00-1.82-.33 1.65 1.65 0 00-1 1.51V21a2 2 0 01-4 0v-.09A1.65 1.65 0 009 19.4a1.65 1.65 0 00-1.82.33l-.06.06a2 2 0 01-2.83-2.83l.06-.06A1.65 1.65 0 004.68 15a1.65 1.65 0 00-1.51-1H3a2 2 0 010-4h.09A1.65 1.65 0 004.6 9a1.65 1.65 0 00-.33-1.82l-.06-.06a2 2 0 012.83-2.83l.06.06A1.65 1.65 0 009 4.68a1.65 1.65 0 001-1.51V3a2 2 0 014 0v.09a1.65 1.65 0 001 1.51 1.65 1.65 0 001.82-.33l.06-.06a2 2 0 012.83 2.83l-.06.06A1.65 1.65 0 0019.4 9a1.65 1.65 0 001.51 1H21a2 2 0 010 4h-.09a1.65 1.65 0 00-1.51 1z" stroke="currentColor" stroke-width="2"/></svg>
<span data-es="CONFIG" data-en="SETTINGS">CONFIG</span>
</button>
</nav>
<!-- RESUMEN -->
<div class="abad-panel abad-panel--active" id="panel-overview">
<div class="abad-kpi-grid">
<div class="abad-kpi abad-kpi--bl">
<div class="abad-kpi__icon"><svg width="18" height="18" viewBox="0 0 24 24" fill="none"><path d="M5 17H3a2 2 0 01-2-2V5a2 2 0 012-2h11a2 2 0 012 2v3M9 17h8a2 2 0 002-2v-5a2 2 0 00-2-2H9a2 2 0 00-2 2v5a2 2 0 002 2z" stroke="currentColor" stroke-width="2" stroke-linecap="round"/><circle cx="7.5" cy="17.5" r="2.5" stroke="currentColor" stroke-width="2"/><circle cx="17.5" cy="17.5" r="2.5" stroke="currentColor" stroke-width="2"/></svg></div>
<div class="abad-kpi__val" id="kpi-trips-today">—</div>
<div class="abad-kpi__label" data-es="Viajes hoy" data-en="Trips today">Viajes hoy</div>
</div>
<div class="abad-kpi abad-kpi--pu">
<div class="abad-kpi__icon"><svg width="18" height="18" viewBox="0 0 24 24" fill="none"><rect x="3" y="4" width="18" height="18" rx="2" stroke="currentColor" stroke-width="2"/><path d="M16 2v4M8 2v4M3 10h18" stroke="currentColor" stroke-width="2" stroke-linecap="round"/></svg></div>
<div class="abad-kpi__val" id="kpi-trips-week">—</div>
<div class="abad-kpi__label" data-es="Esta semana" data-en="This week">Esta semana</div>
</div>
<div class="abad-kpi"><div class="abad-kpi__label" data-es="Activos ahora" data-en="Active now">Activos ahora</div><div class="abad-kpi__val abad-kpi__val--orange" id="kpi-active">—</div></div>
<div class="abad-kpi"><div class="abad-kpi__label" data-es="Conductores online" data-en="Drivers online">Conductores online</div><div class="abad-kpi__val abad-kpi__val--green" id="kpi-online">—</div></div>
<div class="abad-kpi abad-kpi--or">
<div class="abad-kpi__icon"><svg width="18" height="18" viewBox="0 0 24 24" fill="none"><path d="M12 2v20M17 5H9.5a3.5 3.5 0 000 7h5a3.5 3.5 0 010 7H6" stroke="currentColor" stroke-width="2" stroke-linecap="round"/></svg></div>
<div class="abad-kpi__val" id="kpi-active">—</div>
<div class="abad-kpi__label" data-es="Activos ahora" data-en="Active now">Activos ahora</div>
</div>
<div class="abad-kpi abad-kpi--gr">
<div class="abad-kpi__icon"><svg width="18" height="18" viewBox="0 0 24 24" fill="none"><circle cx="12" cy="8" r="4" stroke="currentColor" stroke-width="2"/><path d="M4 20c0-4 3.6-7 8-7s8 3 8 7" stroke="currentColor" stroke-width="2" stroke-linecap="round"/><path d="M17 11l2 2 4-4" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg></div>
<div class="abad-kpi__val" id="kpi-online">—</div>
<div class="abad-kpi__label" data-es="Conductores online" data-en="Drivers online">Conductores online</div>
</div>
<div class="abad-kpi abad-kpi--gr">
<div class="abad-kpi__icon"><svg width="18" height="18" viewBox="0 0 24 24" fill="none"><line x1="12" y1="1" x2="12" y2="23" stroke="currentColor" stroke-width="2" stroke-linecap="round"/><path d="M17 5H9.5a3.5 3.5 0 000 7h5a3.5 3.5 0 010 7H6" stroke="currentColor" stroke-width="2" stroke-linecap="round"/></svg></div>
<div class="abad-kpi__val" id="kpi-revenue">—</div>
<div class="abad-kpi__label" data-es="Ingresos hoy" data-en="Revenue today">Ingresos hoy</div>
</div>
<div class="abad-kpi abad-kpi--re" id="kpi-sos-wrap">
<div class="abad-kpi__icon"><svg width="18" height="18" viewBox="0 0 24 24" fill="none"><path d="M12 9v4M12 17h.01M10.29 3.86L1.82 18a2 2 0 001.71 3h16.94a2 2 0 001.71-3L13.71 3.86a2 2 0 00-3.42 0z" stroke="currentColor" stroke-width="2" stroke-linecap="round"/></svg></div>
<div class="abad-kpi__val" id="kpi-sos">—</div>
<div class="abad-kpi__label" data-es="SOS activos" data-en="Active SOS">SOS activos</div>
</div>
<div class="abad-kpi abad-kpi--ye" id="kpi-pending-wrap">
<div class="abad-kpi__icon"><svg width="18" height="18" viewBox="0 0 24 24" fill="none"><circle cx="12" cy="8" r="4" stroke="currentColor" stroke-width="2"/><path d="M4 20c0-4 3.6-7 8-7s8 3 8 7" stroke="currentColor" stroke-width="2" stroke-linecap="round"/><circle cx="19" cy="11" r="3" stroke="currentColor" stroke-width="2"/><path d="M19 9v2l1 1" stroke="currentColor" stroke-width="2" stroke-linecap="round"/></svg></div>
<div class="abad-kpi__val" id="kpi-pending">—</div>
<div class="abad-kpi__label" data-es="Pendientes aprobacion" data-en="Pending">Pendientes</div>
</div>
</div>
<div class="abad-card">
<div class="abad-card__header">
<span class="abad-card__title" data-es="Viajes - Ultimos 30 dias" data-en="Trips - Last 30 days">Viajes — Ultimos 30 dias</span>
</div>
<div class="abad-card__body">
<div id="abad-overview-chart" class="abad-bar-chart"></div>
</div>
</div>
</div>
<!-- CONDUCTORES -->
<div class="abad-panel" id="panel-drivers" style="display:none">
<div class="abad-toolbar">
<input type="text" class="abad-input" id="driver-search" placeholder="Buscar conductor..." style="max-width:260px">
<select class="abad-select" id="driver-filter">
<option value="">Todos</option>
<option value="online">Online</option>
<option value="offline">Offline</option>
</select>
<div style="flex:1"></div>
<button class="abad-btn abad-btn--outline" id="btn-show-pending">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none"><circle cx="12" cy="12" r="10" stroke="currentColor" stroke-width="2"/><path d="M12 8v4M12 16h.01" stroke="currentColor" stroke-width="2" stroke-linecap="round"/></svg>
<span data-es="Cola de aprobacion" data-en="Approval queue">Cola de aprobacion</span>
<span class="abad-badge abad-badge--inline" id="pending-count-inline"></span>
</button>
</div>
<div class="abad-table-wrap">
<table class="abad-table"><thead><tr>
<th data-es="Conductor" data-en="Driver">Conductor</th>
<th data-es="Vehiculo" data-en="Vehicle">Vehiculo</th>
<th data-es="Estado" data-en="Status">Estado</th>
<th data-es="Rating" data-en="Rating">Rating</th>
<th data-es="Viajes" data-en="Trips">Viajes</th>
<th data-es="Acciones" data-en="Actions">Acciones</th>
</tr></thead><tbody id="drivers-tbody"></tbody></table>
</div>
<div class="abad-pagination" id="drivers-pagination"></div>
<div id="abad-pending-section" style="display:none;margin-top:24px">
<h3 class="abad-section-title" data-es="Conductores pendientes de aprobacion" data-en="Pending approval">Conductores pendientes de aprobacion</h3>
<div id="abad-pending-list" class="abad-cards-grid"></div>
</div>
</div>
<!-- EMPRESAS -->
<div class="abad-panel" id="panel-companies" style="display:none">
<div class="abad-toolbar">
<input type="text" class="abad-input" id="company-search" placeholder="Buscar empresa..." style="max-width:260px">
</div>
<div class="abad-table-wrap">
<table class="abad-table"><thead><tr>
<th data-es="Empresa" data-en="Company">Empresa</th>
<th data-es="Contacto" data-en="Contact">Contacto</th>
<th data-es="Viajes" data-en="Trips">Viajes</th>
<th data-es="Estado" data-en="Status">Estado</th>
<th data-es="Acciones" data-en="Actions">Acciones</th>
</tr></thead><tbody id="companies-tbody"></tbody></table>
</div>
<div class="abad-pagination" id="companies-pagination"></div>
</div>
<!-- VIAJES -->
<div class="abad-panel" id="panel-trips" style="display:none">
<div class="abad-toolbar abad-toolbar--wrap">
<input type="text" class="abad-input abad-input--sm" id="trip-search" placeholder="UUID, conductor, pasajero...">
<input type="date" class="abad-input abad-input--sm" id="trip-date-from">
<input type="date" class="abad-input abad-input--sm" id="trip-date-to">
<select class="abad-select abad-select--sm" id="trip-status-filter">
<option value="">Todos los estados</option>
<option value="assigned">assigned</option>
<option value="en_route">en_route</option>
<option value="waiting">waiting</option>
<option value="in_progress">in_progress</option>
<option value="finished">finished</option>
<option value="canceled">canceled</option>
</select>
<button class="abad-btn abad-btn--brand abad-btn--sm" id="btn-trip-search" data-es="Buscar" data-en="Search">Buscar</button>
<button class="abad-btn abad-btn--outline abad-btn--sm" id="btn-export-trips-csv" data-es="Exportar CSV" data-en="Export CSV">Exportar CSV</button>
</div>
<div class="abad-table-wrap">
<table class="abad-table"><thead><tr>
<th>Fecha</th><th>UUID</th><th>Conductor</th><th>Pasajero</th>
<th>Total</th><th>Estado</th><th>Rating</th><th></th>
</tr></thead><tbody id="trips-tbody"></tbody></table>
</div>
<div class="abad-pagination" id="trips-pagination"></div>
</div>
<!-- INCIDENTES -->
<div class="abad-panel" id="panel-incidents" style="display:none">
<div class="abad-toolbar">
<select class="abad-select" id="incident-filter">
<option value="active" data-es="Activos (SOS)" data-en="Active (SOS)">Activos (SOS)</option>
<option value="attended" data-es="Atendidos" data-en="Attended">Atendidos</option>
<option value="resolved" data-es="Resueltos" data-en="Resolved">Resueltos</option>
<option value="all" data-es="Todos" data-en="All">Todos</option>
</select>
<button class="abad-btn abad-btn--outline abad-btn--sm" id="btn-refresh-incidents">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none"><path d="M23 4v6h-6M1 20v-6h6M3.51 9a9 9 0 0114.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0020.49 15" stroke="currentColor" stroke-width="2" stroke-linecap="round"/></svg>
Actualizar
</button>
</div>
<div id="abad-incidents-list" class="abad-cards-grid"></div>
<div class="abad-pagination" id="incidents-pagination"></div>
</div>
<!-- ZONAS -->
<div class="abad-panel" id="panel-zones" style="display:none">
<div class="abad-toolbar">
<button class="abad-btn abad-btn--brand" id="btn-new-zone">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none"><path d="M12 5v14M5 12h14" stroke="currentColor" stroke-width="2" stroke-linecap="round"/></svg>
<span data-es="Nueva zona" data-en="New zone">Nueva zona</span>
</button>
</div>
<div id="abad-zones-map" class="abad-map"></div>
<div id="abad-zones-list" class="abad-cards-grid" style="margin-top:16px"></div>
</div>
<!-- CONFIG -->
<div class="abad-panel" id="panel-settings" style="display:none">
<div class="abad-settings-grid">
<div class="abad-card">
<div class="abad-card__title" data-es="Plataforma" data-en="Platform">Plataforma</div>
<div class="abad-form-group"><label class="abad-label" data-es="Nombre" data-en="Name">Nombre</label><input type="text" class="abad-input" id="cfg-name"></div>
<div class="abad-form-group"><label class="abad-label">Logo URL</label><input type="url" class="abad-input" id="cfg-logo"></div>
<div class="abad-form-group"><label class="abad-label" data-es="Email SOS" data-en="SOS email">Email SOS</label><input type="email" class="abad-input" id="cfg-sos-email"></div>
<div class="abad-form-group"><label class="abad-label" data-es="Comision global (%)" data-en="Global commission (%)">Comision global (%)</label><input type="number" class="abad-input" id="cfg-fee-pct" min="0" max="100" step="0.1"></div>
<div class="abad-alert abad-alert--success" id="cfg-success" style="display:none">Guardado.</div>
<button class="abad-btn abad-btn--brand abad-btn--wide" id="btn-save-settings" data-es="GUARDAR" data-en="SAVE">GUARDAR</button>
</div>
<div class="abad-card">
<div class="abad-card__title" data-es="Tarifas por pais" data-en="Fares by country">Tarifas por pais</div>
<p class="abad-note" data-es="Fuente central de tarifas. Requiere actualizacion de plugins de pasajero y corporativo para leerla (ver CHANGES.md)." data-en="Central fare source. Requires updating passenger and corporate plugins to read it (see CHANGES.md).">Fuente central de tarifas. Ver CHANGES.md para informacion sobre la inconsistencia actual (millas vs km).</p>
<div class="abad-form-row">
<div class="abad-form-group"><label class="abad-label">Pais (ISO)</label><input type="text" class="abad-input" id="fare-country" maxlength="2" style="text-transform:uppercase"></div>
<div class="abad-form-group"><label class="abad-label">Moneda</label><input type="text" class="abad-input" id="fare-currency" maxlength="3" style="text-transform:uppercase" placeholder="USD"></div>
</div>
<div class="abad-form-row">
<div class="abad-form-group"><label class="abad-label" data-es="Base ($)" data-en="Base ($)">Base ($)</label><input type="number" class="abad-input" id="fare-base" min="0" step="0.01"></div>
<div class="abad-form-group"><label class="abad-label" data-es="Por km ($)" data-en="Per km ($)">Por km</label><input type="number" class="abad-input" id="fare-per-km" min="0" step="0.001"></div>
<div class="abad-form-group"><label class="abad-label" data-es="Por min ($)" data-en="Per min ($)">Por min</label><input type="number" class="abad-input" id="fare-per-min" min="0" step="0.001"></div>
<div class="abad-form-group"><label class="abad-label" data-es="Minimo ($)" data-en="Min ($)">Minimo</label><input type="number" class="abad-input" id="fare-minimum" min="0" step="0.01"></div>
</div>
<div class="abad-alert abad-alert--success" id="fare-success" style="display:none">Tarifa guardada.</div>
<button class="abad-btn abad-btn--brand" id="btn-save-fare" data-es="GUARDAR TARIFA" data-en="SAVE FARE">GUARDAR TARIFA</button>
<div class="abad-card__title" style="margin-top:20px" data-es="Tarifas configuradas" data-en="Configured fares">Tarifas configuradas</div>
<div id="abad-fare-list"></div>
</div>
<div class="abad-card">
<div class="abad-card__title">Seguridad</div>
<div class="abad-form-group"><label class="abad-label">IPs en whitelist (una por línea)</label><textarea class="abad-textarea" id="sec-whitelist" rows="3" placeholder="127.0.0.1"></textarea></div>
<div class="abad-form-group"><label class="abad-label">hCaptcha Site Key</label><input type="text" class="abad-input" id="sec-hcaptcha-site"></div>
<div class="abad-form-group"><label class="abad-label">hCaptcha Secret Key</label><input type="password" class="abad-input" id="sec-hcaptcha-secret"></div>
<div class="abad-alert abad-alert--success" id="sec-saved" style="display:none">Guardado.</div>
<button class="abad-btn abad-btn--brand" id="btn-save-sec">GUARDAR SEGURIDAD</button>
<div class="abad-card__title" style="margin-top:20px">IPs bloqueadas</div>
<p class="abad-note" id="sec-blocked-count">—</p>
<div id="sec-blocked-table"></div>
</div>
</div>
</div>
<!-- MODALES -->
<div class="abad-overlay" id="modal-reason" style="display:none">
<div class="abad-modal">
<h3 id="modal-reason-title">Motivo</h3>
<textarea class="abad-textarea" id="modal-reason-text" rows="3" placeholder="Motivo (opcional)..."></textarea>
<div class="abad-modal__actions">
<button class="abad-btn abad-btn--danger" id="modal-reason-confirm">Confirmar</button>
<button class="abad-btn abad-btn--outline" id="modal-reason-cancel">Cancelar</button>
</div>
</div>
</div>
<div class="abad-overlay" id="modal-trip" style="display:none">
<div class="abad-modal abad-modal--wide">
<button class="abad-modal__close" id="modal-trip-close"><svg width="18" height="18" viewBox="0 0 24 24" fill="none"><path d="M18 6L6 18M6 6l12 12" stroke="currentColor" stroke-width="2" stroke-linecap="round"/></svg></button>
<h3 data-es="Detalle del viaje" data-en="Trip detail">Detalle del viaje</h3>
<div id="modal-trip-map" class="abad-map abad-map--modal"></div>
<div id="modal-trip-content"></div>
</div>
</div>
<div class="abad-overlay" id="modal-incident" style="display:none">
<div class="abad-modal">
<button class="abad-modal__close" id="modal-incident-close"><svg width="18" height="18" viewBox="0 0 24 24" fill="none"><path d="M18 6L6 18M6 6l12 12" stroke="currentColor" stroke-width="2" stroke-linecap="round"/></svg></button>
<h3 data-es="Incidente SOS" data-en="SOS Incident">Incidente SOS</h3>
<div id="modal-incident-map" class="abad-map abad-map--modal"></div>
<div id="modal-incident-content"></div>
<div class="abad-form-group" style="margin-top:12px">
<label class="abad-label" data-es="Nota de resolucion" data-en="Resolution note">Nota de resolucion</label>
<textarea class="abad-textarea" id="incident-note" rows="2"></textarea>
</div>
<div class="abad-modal__actions">
<button class="abad-btn abad-btn--brand" id="btn-incident-attended" data-es="Marcar atendido" data-en="Mark attended">Marcar atendido</button>
<button class="abad-btn abad-btn--outline" id="btn-incident-resolved" data-es="Marcar resuelto" data-en="Mark resolved">Marcar resuelto</button>
</div>
</div>
</div>
<div class="abad-overlay" id="modal-zone" style="display:none">
<div class="abad-modal abad-modal--wide">
<button class="abad-modal__close" id="modal-zone-close"><svg width="18" height="18" viewBox="0 0 24 24" fill="none"><path d="M18 6L6 18M6 6l12 12" stroke="currentColor" stroke-width="2" stroke-linecap="round"/></svg></button>
<h3 id="zone-modal-title" data-es="Zona de alerta" data-en="Alert zone">Zona de alerta</h3>
<input type="hidden" id="zone-id">
<div id="zone-modal-map" class="abad-map abad-map--modal"></div>
<div class="abad-form-row">
<div class="abad-form-group" style="flex:2"><label class="abad-label">Titulo</label><input type="text" class="abad-input" id="zone-title"></div>
<div class="abad-form-group"><label class="abad-label">Tipo</label>
<select class="abad-select" id="zone-type"><option value="hotspot">Hotspot</option><option value="bonus">Bonus</option><option value="warning">Warning</option></select>
</div>
</div>
<div class="abad-form-group"><label class="abad-label">Descripcion</label><textarea class="abad-textarea" id="zone-description" rows="2"></textarea></div>
<div class="abad-form-row">
<div class="abad-form-group"><label class="abad-label">Radio (km)</label><input type="number" class="abad-input" id="zone-radius" min="0.1" step="0.1" value="2"></div>
<div class="abad-form-group"><label class="abad-label">Bono ($)</label><input type="number" class="abad-input" id="zone-bonus" min="0" step="0.01" value="0"></div>
<div class="abad-form-group"><label class="abad-label">Countdown (s)</label><input type="number" class="abad-input" id="zone-countdown" min="60" value="180"></div>
</div>
<div class="abad-form-group"><label class="abad-label">Expira (opcional)</label><input type="datetime-local" class="abad-input" id="zone-expires"></div>
<p class="abad-note" data-es="Haz clic en el mapa para posicionar la zona." data-en="Click the map to position the zone.">Haz clic en el mapa para posicionar la zona.</p>
<input type="hidden" id="zone-lat">
<input type="hidden" id="zone-lng">
<div class="abad-modal__actions">
<button class="abad-btn abad-btn--brand" id="btn-zone-save" data-es="Guardar zona" data-en="Save zone">Guardar zona</button>
<button class="abad-btn abad-btn--outline" id="btn-zone-cancel">Cancelar</button>
</div>
</div>
</div>
</div>
<?php
return ob_get_clean();
}
}
ABAdminDashboard::get_instance();