Files
AutoBooking/autobooking-command-center.php

388 lines
18 KiB
PHP

<?php
/**
* Plugin Name: AutoBooking Command Center
* Description: Centro de control operativo en tiempo real para supervisores de despacho
* Version: 1.0.0
* Author: AutoBooking
*/
if (!defined('ABSPATH')) exit;
/* ── Enmascaramiento de teléfonos ─────────────────────────────────────
* Admin/manage_options → número completo.
* Cualquier otro rol → últimos 4 dígitos visibles, resto enmascarado.
* ─────────────────────────────────────────────────────────────────── */
function ab_mask_phone( $phone ) {
if ( ! $phone ) return '';
if ( current_user_can('manage_options') || current_user_can('manage_autobooking') ) {
return $phone; // admin ve el número completo
}
// Conservar solo dígitos para contar
$digits = preg_replace('/\D/', '', $phone);
$len = strlen($digits);
if ( $len < 4 ) return str_repeat('*', $len);
// Máscara: *** *** **XX (últimos 4 visibles)
$visible = substr($digits, -4);
$masked = str_repeat('*', max(0, $len - 4));
$masked_fmt = chunk_split($masked, 3, ' ');
// Preservar prefijo + si existe
$prefix = (substr(trim($phone), 0, 1) === '+') ? '+' : '';
return trim($prefix . trim($masked_fmt) . $visible);
}
class AutoBookingCommandCenter {
public function __construct() {
add_action('init', [$this, 'register_role']);
add_action('rest_api_init', [$this, 'register_endpoints']);
add_shortcode('autobooking_command_center', [$this, 'render']);
add_action('wp_enqueue_scripts', [$this, 'enqueue']);
}
public function register_role() {
if (!get_role('dispatch_operator')) {
add_role('dispatch_operator', 'Operador de Despacho', [
'read' => true,
'dispatch_operations' => true,
]);
}
}
public function can_access() {
return is_user_logged_in() && (
current_user_can('manage_autobooking') ||
current_user_can('dispatch_operations') ||
current_user_can('manage_options')
);
}
/* ── REST endpoints ──────────────────────────────────────── */
public function register_endpoints() {
$ns = 'autobooking/v1';
$auth = [$this, 'can_access'];
register_rest_route($ns, '/command/stats', ['methods' => 'GET', 'callback' => [$this, 'ep_stats'], 'permission_callback' => $auth]);
register_rest_route($ns, '/command/live-map', ['methods' => 'GET', 'callback' => [$this, 'ep_live_map'], 'permission_callback' => $auth]);
register_rest_route($ns, '/command/alerts', ['methods' => 'GET', 'callback' => [$this, 'ep_alerts'], 'permission_callback' => $auth]);
register_rest_route($ns, '/command/trip/(?P<id>\d+)', ['methods' => 'GET', 'callback' => [$this, 'ep_trip'], 'permission_callback' => $auth]);
register_rest_route($ns, '/command/incident', ['methods' => 'POST', 'callback' => [$this, 'ep_incident'], 'permission_callback' => $auth]);
register_rest_route($ns, '/command/resolve/(?P<id>\d+)',['methods' => 'POST', 'callback' => [$this, 'ep_resolve'], 'permission_callback' => $auth]);
register_rest_route($ns, '/command/block-driver', ['methods' => 'POST', 'callback' => [$this, 'ep_block_driver'], 'permission_callback' => $auth]);
register_rest_route($ns, '/command/message', ['methods' => 'POST', 'callback' => [$this, 'ep_message'], 'permission_callback' => $auth]);
}
public function ep_stats() {
global $wpdb;
return rest_ensure_response([
'active_trips' => (int)$wpdb->get_var("SELECT COUNT(*) FROM wp_ab_trips WHERE status IN ('active','en_route','picking_up','in_progress')"),
'available_drivers' => (int)$wpdb->get_var("SELECT COUNT(*) FROM wp_ab_driver_status WHERE online=1 AND last_seen > DATE_SUB(NOW(), INTERVAL 5 MINUTE)"),
'open_sos' => (int)$wpdb->get_var("SELECT COUNT(*) FROM wp_ab_incidents WHERE status IN ('open','active','pending')"),
'trips_today' => (int)$wpdb->get_var("SELECT COUNT(*) FROM wp_ab_trips WHERE DATE(created_at) = CURDATE()"),
]);
}
public function ep_live_map() {
global $wpdb;
$trips = $wpdb->get_results("
SELECT t.id, t.status, t.driver_id, t.passenger_id,
t.driver_name, t.passenger_name,
t.pickup_time AS started_at,
t.dropoff_lat, t.dropoff_lng,
p.lat AS driver_lat, p.lng AS driver_lng
FROM wp_ab_trips t
LEFT JOIN (
SELECT trip_id, lat, lng
FROM wp_ab_trip_positions
WHERE id IN (SELECT MAX(id) FROM wp_ab_trip_positions GROUP BY trip_id)
) p ON t.id = p.trip_id
WHERE t.status IN ('active','en_route','picking_up','in_progress')
");
$drivers = $wpdb->get_results("
SELECT ds.driver_id AS user_id, ds.last_lat AS lat, ds.last_lng AS lng, u.display_name
FROM wp_ab_driver_status ds
JOIN wp_users u ON ds.driver_id = u.ID
WHERE ds.online = 1
AND ds.last_seen > DATE_SUB(NOW(), INTERVAL 5 MINUTE)
");
return rest_ensure_response(['trips' => $trips, 'drivers' => $drivers]);
}
public function ep_alerts() {
global $wpdb;
$sos = $wpdb->get_results("
SELECT i.id, i.trip_id, i.user_id, i.type, i.description,
i.lat, i.lng, i.status, i.created_at,
u.display_name AS user_name,
t.driver_id, t.passenger_id
FROM wp_ab_incidents i
LEFT JOIN wp_users u ON i.user_id = u.ID
LEFT JOIN wp_ab_trips t ON i.trip_id = t.id
WHERE i.status IN ('open','active','pending')
ORDER BY i.created_at DESC
LIMIT 30
");
return rest_ensure_response(['sos' => $sos, 'count' => count($sos)]);
}
public function ep_trip($req) {
global $wpdb;
$id = (int)$req['id'];
$trip = $wpdb->get_row($wpdb->prepare("
SELECT t.*,
du.display_name AS driver_name,
pu.display_name AS passenger_name,
dm.meta_value AS driver_phone,
pm.meta_value AS passenger_phone
FROM wp_ab_trips t
LEFT JOIN wp_users du ON t.driver_id = du.ID
LEFT JOIN wp_users pu ON t.passenger_id = pu.ID
LEFT JOIN wp_usermeta dm ON t.driver_id = dm.user_id AND dm.meta_key = 'phone'
LEFT JOIN wp_usermeta pm ON t.passenger_id = pm.user_id AND pm.meta_key = 'phone'
WHERE t.id = %d
", $id));
if (!$trip) return new WP_Error('not_found', 'Trip not found', ['status' => 404]);
// Posición actual del conductor
$cur = $wpdb->get_row($wpdb->prepare(
"SELECT lat, lng FROM wp_ab_trip_positions WHERE trip_id = %d ORDER BY id DESC LIMIT 1", $id
));
$trip->current_lat = $cur ? $cur->lat : null;
$trip->current_lng = $cur ? $cur->lng : null;
// Enmascarar teléfonos según rol
$trip->driver_phone = ab_mask_phone($trip->driver_phone ?? '');
$trip->passenger_phone = ab_mask_phone($trip->passenger_phone ?? '');
$incidents = $wpdb->get_results($wpdb->prepare(
"SELECT * FROM wp_ab_incidents WHERE trip_id = %d ORDER BY created_at DESC LIMIT 10", $id
));
return rest_ensure_response(['trip' => $trip, 'incidents' => $incidents]);
}
public function ep_incident($req) {
global $wpdb;
$p = $req->get_params();
$trip_id = (int)($p['trip_id'] ?? 0);
$type = sanitize_text_field($p['type'] ?? 'operator_action');
$desc = sanitize_textarea_field($p['description'] ?? '');
$protocol = sanitize_text_field($p['protocol'] ?? '');
$wpdb->insert('wp_ab_incidents', [
'trip_id' => $trip_id,
'user_id' => get_current_user_id(),
'type' => $type,
'description' => $desc . ($protocol ? " [PROTOCOLO: {$protocol}]" : ''),
'status' => 'active',
'created_at' => current_time('mysql'),
]);
$this->audit('command_incident', compact('trip_id', 'type', 'protocol'));
return rest_ensure_response(['success' => true, 'id' => $wpdb->insert_id]);
}
public function ep_resolve($req) {
global $wpdb;
$id = (int)$req['id'];
$notes = sanitize_textarea_field($req->get_param('notes') ?? '');
$wpdb->update('wp_ab_incidents', [
'status' => 'resolved',
'resolved_at' => current_time('mysql'),
'resolution_notes' => $notes,
], ['id' => $id]);
$this->audit('command_resolve', ['alert_id' => $id, 'notes' => $notes]);
return rest_ensure_response(['success' => true]);
}
public function ep_block_driver($req) {
global $wpdb;
$driver_id = (int)($req->get_param('driver_id') ?? 0);
$reason = sanitize_text_field($req->get_param('reason') ?? '');
if (!$driver_id) return new WP_Error('invalid', 'Driver ID required', ['status' => 400]);
$wpdb->update('wp_ab_driver_status', ['online' => 0], ['driver_id' => $driver_id]);
$this->audit('command_block_driver', compact('driver_id', 'reason'));
return rest_ensure_response(['success' => true]);
}
public function ep_message($req) {
global $wpdb;
$trip_id = (int)($req->get_param('trip_id') ?? 0);
$to_user_id = (int)($req->get_param('to_user_id') ?? 0);
$message = sanitize_textarea_field($req->get_param('message') ?? '');
if (!$message) return new WP_Error('invalid', 'Message required', ['status' => 400]);
$wpdb->insert('wp_autobooking_chat', [
'trip_id' => $trip_id,
'from_user_id' => get_current_user_id(),
'to_user_id' => $to_user_id,
'message' => '[OPERADOR] ' . $message,
'created_at' => current_time('mysql'),
]);
return rest_ensure_response(['success' => true]);
}
private function audit($action, $details) {
global $wpdb;
$wpdb->insert('wp_ab_admin_audit', [
'admin_id' => get_current_user_id(),
'action' => $action,
'details' => wp_json_encode($details),
'created_at' => current_time('mysql'),
]);
}
/* ── Assets ──────────────────────────────────────────────── */
public function enqueue() {
global $post;
if (!$post || !has_shortcode($post->post_content, 'autobooking_command_center')) return;
if (!$this->can_access()) return;
$url = plugin_dir_url(__FILE__);
wp_enqueue_style('cc-style', $url . 'command-center.css', [], '1.0.0');
wp_enqueue_script('cc-script', $url . 'command-center.js',
['jquery'], '1.0.0', true
);
wp_localize_script('cc-script', 'ccConfig', [
'rest' => rest_url('autobooking/v1/'),
'nonce' => wp_create_nonce('wp_rest'),
'userId' => get_current_user_id(),
]);
}
/* ── Shortcode HTML ──────────────────────────────────────── */
public function render() {
if (!$this->can_access())
return '<p style="color:#ff4444;text-align:center;padding:2rem;">Acceso no autorizado.</p>';
ob_start(); ?>
<div id="cc-app">
<!-- Header -->
<div id="cc-header">
<div class="cc-brand">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none">
<circle cx="12" cy="12" r="10" stroke="#FF6F00" stroke-width="2"/>
<path d="M12 8v4l3 3" stroke="#FF6F00" stroke-width="2" stroke-linecap="round"/>
</svg>
COMANDO CENTRAL
</div>
<div class="cc-kpis">
<div class="cc-kpi"><span id="kpi-active">--</span><small>Viajes activos</small></div>
<div class="cc-kpi"><span id="kpi-free">--</span><small>Conductores libres</small></div>
<div class="cc-kpi cc-kpi-sos" id="kpi-sos-box"><span id="kpi-sos">0</span><small>Alertas SOS</small></div>
<div class="cc-kpi"><span id="kpi-today">--</span><small>Viajes hoy</small></div>
</div>
<div id="cc-theme-switcher">
<button class="cc-theme-btn" data-theme="dark" title="Oscuro">Oscuro</button>
<button class="cc-theme-btn" data-theme="improved" title="Mejorado">Mejorado</button>
<button class="cc-theme-btn" data-theme="normal" title="Normal">Normal</button>
</div>
<div id="cc-clock">--:--:--</div>
</div>
<!-- Main body -->
<div id="cc-body">
<!-- Live map -->
<div id="cc-map-wrap">
<div id="cc-map"></div>
<div id="cc-legend">
<span><i class="leg-dot leg-trip"></i>En viaje</span>
<span><i class="leg-dot leg-free"></i>Disponible</span>
<span><i class="leg-dot leg-sos"></i>SOS</span>
</div>
</div>
<!-- Right sidebar -->
<div id="cc-sidebar">
<!-- SOS alerts -->
<div class="cc-section" id="cc-sos-section">
<div class="cc-sec-hdr sos-hdr">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none">
<path d="M10.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="#ff4444" stroke-width="2"/>
<line x1="12" y1="9" x2="12" y2="13" stroke="#ff4444" stroke-width="2"/>
<line x1="12" y1="17" x2="12.01" y2="17" stroke="#ff4444" stroke-width="3"/>
</svg>
ALERTAS SOS
<span id="sos-count-badge" class="cc-badge">0</span>
</div>
<div id="sos-list"><div class="cc-empty">Sin alertas activas</div></div>
</div>
<!-- Active trips list -->
<div class="cc-section">
<div class="cc-sec-hdr">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none">
<rect x="1" y="3" width="15" height="13" rx="2" stroke="#FF6F00" stroke-width="2"/>
<path d="M16 8h4l3 3v5h-7V8z" stroke="#FF6F00" stroke-width="2"/>
<circle cx="5.5" cy="18.5" r="2.5" stroke="#FF6F00" stroke-width="2"/>
<circle cx="18.5" cy="18.5" r="2.5" stroke="#FF6F00" stroke-width="2"/>
</svg>
VIAJES ACTIVOS
</div>
<div id="trips-list"><div class="cc-empty">Sin viajes activos</div></div>
</div>
<!-- Trip detail panel (sidebar) -->
<div class="cc-section cc-trip-panel-section" id="cc-trip-panel" style="display:none">
<div class="cc-sec-hdr">
<svg width="14" height="14" viewBox="0 0 24 40">
<path d="M4,12 Q4,4 12,2 Q20,4 20,12 L20,30 Q20,38 12,38 Q4,38 4,30 Z" fill="#FF6F00" opacity=".9"/>
<path d="M7,10 Q12,6 17,10 L16,17 Q12,15 8,17 Z" fill="rgba(255,255,255,0.4)"/>
</svg>
<span id="cc-panel-title">VIAJE SELECCIONADO</span>
<button class="cc-panel-close" onclick="CC.closeDetail()">✕</button>
</div>
<div class="cc-panel-body">
<div id="cc-panel-info"></div>
<div id="cc-panel-pattern"></div>
<div id="cc-panel-btns"></div>
</div>
</div>
</div>
</div>
<!-- Protocol modal -->
<div id="cc-modal-protocol" class="cc-modal">
<div class="cc-modal-inner">
<h3>Activar Protocolo de Seguridad</h3>
<div class="proto-grid">
<button class="proto-opt" data-p="accidente" onclick="CC.selectProtocol(this)">🚗 Accidente vial</button>
<button class="proto-opt" data-p="robo" onclick="CC.selectProtocol(this)">🔫 Robo / Asalto</button>
<button class="proto-opt" data-p="medico" onclick="CC.selectProtocol(this)">🏥 Emergencia médica</button>
<button class="proto-opt" data-p="desaparicion" onclick="CC.selectProtocol(this)">❓ Desaparición</button>
<button class="proto-opt" data-p="acoso" onclick="CC.selectProtocol(this)">⚠️ Acoso / Abuso</button>
<button class="proto-opt" data-p="ruta_sospechosa" onclick="CC.selectProtocol(this)">🗺️ Ruta sospechosa</button>
</div>
<textarea id="proto-notes" placeholder="Notas adicionales del operador..." rows="3"></textarea>
<div class="cc-modal-btns">
<button class="ccb ccb-dark" onclick="CC.closeModal('protocol')">Cancelar</button>
<button class="ccb ccb-red" onclick="CC.confirmProtocol()">Activar Protocolo</button>
</div>
</div>
</div>
<!-- Message modal -->
<div id="cc-modal-message" class="cc-modal">
<div class="cc-modal-inner">
<h3>Enviar Mensaje del Operador</h3>
<select id="msg-to">
<option value="driver">Al Conductor</option>
<option value="passenger">Al Pasajero</option>
</select>
<textarea id="msg-body" placeholder="Escribe el mensaje..." rows="4"></textarea>
<div class="cc-modal-btns">
<button class="ccb ccb-dark" onclick="CC.closeModal('message')">Cancelar</button>
<button class="ccb ccb-green" onclick="CC.sendMessage()">Enviar</button>
</div>
</div>
</div>
</div>
<?php
return ob_get_clean();
}
}
new AutoBookingCommandCenter();