Modificación de Mayusculas en clase documneto tipo
This commit is contained in:
@@ -20,7 +20,17 @@
|
|||||||
"Bash(php -l app/Models/Entrega.php)",
|
"Bash(php -l app/Models/Entrega.php)",
|
||||||
"Bash(php -l app/Livewire/GrupoDocente.php)",
|
"Bash(php -l app/Livewire/GrupoDocente.php)",
|
||||||
"Bash(composer require *)",
|
"Bash(composer require *)",
|
||||||
"Bash(Select-String -Pattern \"entrega|Entrega\" -Context 0,0)"
|
"Bash(Select-String -Pattern \"entrega|Entrega\" -Context 0,0)",
|
||||||
|
"Bash(php -l app/Services/WhatsAppService.php)",
|
||||||
|
"Bash(php -l app/Http/Controllers/WhatsAppWebhookController.php)",
|
||||||
|
"Bash(php -l app/Http/Controllers/WhatsAppAnalyticsController.php)",
|
||||||
|
"Bash(Test-Path *)",
|
||||||
|
"Bash(git -C \"C:/Proyectos/SistemaEducativoLaravel\" status)",
|
||||||
|
"Bash(git -C \"C:/Proyectos/SistemaEducativoLaravel\" diff)",
|
||||||
|
"Bash(git *)",
|
||||||
|
"Bash(dir C:\\\\Proyectos\\\\SistemaEducativoLaravel\\\\public *)",
|
||||||
|
"Bash(findstr \"APP_URL\" \"C:\\\\Proyectos\\\\SistemaEducativoLaravel\\\\.env.example\")",
|
||||||
|
"Bash(findstr \"APP_URL\" \"C:\\\\Proyectos\\\\SistemaEducativoLaravel\\\\.env\")"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
.git
|
||||||
|
.env
|
||||||
|
node_modules
|
||||||
|
public/storage
|
||||||
|
storage/framework/cache
|
||||||
|
storage/framework/sessions
|
||||||
|
storage/framework/views
|
||||||
|
storage/logs
|
||||||
+35
-19
@@ -40,26 +40,42 @@ QUEUE_CONNECTION=database
|
|||||||
CACHE_STORE=database
|
CACHE_STORE=database
|
||||||
# CACHE_PREFIX=
|
# CACHE_PREFIX=
|
||||||
|
|
||||||
MEMCACHED_HOST=127.0.0.1
|
APP_NAME="Sistema Educativo CUIEP"
|
||||||
|
APP_ENV=local
|
||||||
|
APP_KEY=base64:1EprnplCgW3bOVa+E65gRv3OUX+ls4Pylw50UqGgiWU=
|
||||||
|
APP_DEBUG=true
|
||||||
|
APP_URL=http://localhost:8000
|
||||||
|
APP_LOCALE=es
|
||||||
|
|
||||||
REDIS_CLIENT=phpredis
|
DB_CONNECTION=mysql
|
||||||
REDIS_HOST=127.0.0.1
|
DB_HOST=127.0.0.1
|
||||||
REDIS_PASSWORD=null
|
DB_PORT=3306
|
||||||
REDIS_PORT=6379
|
DB_DATABASE=sistemaeducativo
|
||||||
|
DB_USERNAME=laravel
|
||||||
|
DB_PASSWORD=
|
||||||
|
|
||||||
MAIL_MAILER=log
|
SESSION_DRIVER=file
|
||||||
MAIL_SCHEME=null
|
QUEUE_CONNECTION=database
|
||||||
MAIL_HOST=127.0.0.1
|
BROADCAST_CONNECTION=reverb
|
||||||
MAIL_PORT=2525
|
|
||||||
MAIL_USERNAME=null
|
|
||||||
MAIL_PASSWORD=null
|
|
||||||
MAIL_FROM_ADDRESS="hello@example.com"
|
|
||||||
MAIL_FROM_NAME="${APP_NAME}"
|
|
||||||
|
|
||||||
AWS_ACCESS_KEY_ID=
|
REVERB_APP_ID=748566
|
||||||
AWS_SECRET_ACCESS_KEY=
|
REVERB_APP_KEY=fmoknd2swp3clsqfbdpg
|
||||||
AWS_DEFAULT_REGION=us-east-1
|
REVERB_APP_SECRET=vahbjut789g3d7rdphzg
|
||||||
AWS_BUCKET=
|
REVERB_HOST=127.0.0.1
|
||||||
AWS_USE_PATH_STYLE_ENDPOINT=false
|
REVERB_PORT=8080
|
||||||
|
REVERB_SCHEME=http
|
||||||
|
|
||||||
VITE_APP_NAME="${APP_NAME}"
|
VITE_REVERB_HOST=127.0.0.1
|
||||||
|
VITE_REVERB_PORT=8080
|
||||||
|
VITE_REVERB_SCHEME=http
|
||||||
|
VITE_REVERB_APP_KEY="${REVERB_APP_KEY}"
|
||||||
|
|
||||||
|
OPENPAY_ID=
|
||||||
|
OPENPAY_PRIVATE_KEY=
|
||||||
|
OPENPAY_PUBLIC_KEY=
|
||||||
|
OPENPAY_PRODUCTION=false
|
||||||
|
|
||||||
|
WHATSAPP_TOKEN=EAANZBf4YEsjABRnUjl4d5PQS33GwnMZA6u9nFLvXpJAR4ZAlMEjg33AOPI6ommzCuQZAjZABJ4QclDptJXA0aGvZCPzL2dhiELU5mZBEJ2eZALu4SbZAaQQ6umgq7uCSh4yOsk58urzM9P6W5JMZBbl1LT5ZBqmCvBKKC0rAFtZBZB2GiwN54mlSZAifZAq9kbuUuZAzkwZDZD
|
||||||
|
WHATSAPP_PHONE_ID=1168611189662826
|
||||||
|
WHATSAPP_WABA_ID=1652562415964289
|
||||||
|
WHATSAPP_VERIFY_TOKEN=muronegro_webhook_2026
|
||||||
|
|||||||
+3
-2
@@ -22,9 +22,10 @@ RUN npm ci && \
|
|||||||
npm run build
|
npm run build
|
||||||
|
|
||||||
RUN mkdir -p storage/framework/sessions storage/framework/views storage/framework/cache storage/app/livewire-tmp storage/logs \
|
RUN mkdir -p storage/framework/sessions storage/framework/views storage/framework/cache storage/app/livewire-tmp storage/logs \
|
||||||
&& chmod -R 775 storage bootstrap/cache
|
&& chmod -R 775 storage bootstrap/cache \
|
||||||
|
&& chown -R nobody:nginx storage bootstrap/cache
|
||||||
|
|
||||||
RUN php artisan storage:link
|
RUN rm -rf public/storage && php artisan storage:link
|
||||||
|
|
||||||
COPY nginx.conf /etc/nginx/nginx.conf
|
COPY nginx.conf /etc/nginx/nginx.conf
|
||||||
COPY supervisord.conf /etc/supervisord.conf
|
COPY supervisord.conf /etc/supervisord.conf
|
||||||
|
|||||||
@@ -37,12 +37,21 @@ class WhatsAppAnalyticsController extends Controller
|
|||||||
$query->where('created_at', '<=', $request->fecha_hasta . ' 23:59:59');
|
$query->where('created_at', '<=', $request->fecha_hasta . ' 23:59:59');
|
||||||
}
|
}
|
||||||
|
|
||||||
$enviados = (clone $query)->where('tipo', 'enviado')->count();
|
// Métricas siempre sobre todos los tipos (sin filtro de tipo)
|
||||||
$fallidos = (clone $query)->where('tipo', 'error')->count();
|
$enviados = (clone $query)->where('tipo', 'enviado')->count();
|
||||||
$optOuts = (clone $query)->where('tipo', 'opt_out')->count();
|
$fallidos = (clone $query)->where('tipo', 'error')->count();
|
||||||
$recibidos = (clone $query)->where('tipo', 'recibido')->count();
|
$optOuts = (clone $query)->where('tipo', 'opt_out')->count();
|
||||||
|
$recibidos = (clone $query)->where('tipo', 'recibido')->count();
|
||||||
|
$entregados = (clone $query)->where('tipo', 'status_delivered')->count();
|
||||||
|
$leidos = (clone $query)->where('tipo', 'status_read')->count();
|
||||||
|
|
||||||
$logsRecientes = (clone $query)
|
// El filtro de tipo solo aplica al listado de logs
|
||||||
|
$logsQuery = clone $query;
|
||||||
|
if ($request->filled('tipo')) {
|
||||||
|
$logsQuery->where('tipo', $request->tipo);
|
||||||
|
}
|
||||||
|
|
||||||
|
$logsRecientes = $logsQuery
|
||||||
->with('alumno.alumnos')
|
->with('alumno.alumnos')
|
||||||
->latest('created_at')
|
->latest('created_at')
|
||||||
->paginate(20);
|
->paginate(20);
|
||||||
@@ -52,7 +61,7 @@ class WhatsAppAnalyticsController extends Controller
|
|||||||
})->latest()->get();
|
})->latest()->get();
|
||||||
|
|
||||||
return view('promocion.analytics', compact(
|
return view('promocion.analytics', compact(
|
||||||
'enviados', 'fallidos', 'optOuts', 'recibidos',
|
'enviados', 'fallidos', 'optOuts', 'recibidos', 'entregados', 'leidos',
|
||||||
'logsRecientes', 'planteles', 'promociones'
|
'logsRecientes', 'planteles', 'promociones'
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,115 +2,60 @@
|
|||||||
|
|
||||||
namespace App\Http\Controllers;
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
use App\Events\NuevoMensajeWhatsApp;
|
|
||||||
use App\Models\Alumno;
|
|
||||||
use App\Models\WhatsappLog;
|
use App\Models\WhatsappLog;
|
||||||
use App\Services\WhatsAppService;
|
use App\Services\WhatsAppService;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Support\Facades\Log;
|
use Illuminate\Http\Response;
|
||||||
|
|
||||||
class WhatsAppWebhookController extends Controller
|
class WhatsAppWebhookController extends Controller
|
||||||
{
|
{
|
||||||
private array $optOutKeywords = ['stop', 'no', 'cancelar', 'detener', 'baja', 'salir'];
|
public function verify(Request $request): Response
|
||||||
|
|
||||||
public function verify(Request $request)
|
|
||||||
{
|
{
|
||||||
$verifyToken = config('services.whatsapp.verify_token');
|
$verifyToken = config('services.whatsapp.verify_token');
|
||||||
|
|
||||||
if (
|
if (
|
||||||
$request->get('hub_mode') === 'subscribe' &&
|
$request->query('hub_mode') === 'subscribe' &&
|
||||||
$request->get('hub_verify_token') === $verifyToken
|
$request->query('hub_verify_token') === $verifyToken
|
||||||
) {
|
) {
|
||||||
return response($request->get('hub_challenge'), 200);
|
return response($request->query('hub_challenge'), 200);
|
||||||
}
|
}
|
||||||
|
|
||||||
return response('Forbidden', 403);
|
return response('Forbidden', 403);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function handle(Request $request, WhatsAppService $whatsapp)
|
public function handle(Request $request, WhatsAppService $whatsapp): JsonResponse
|
||||||
{
|
{
|
||||||
$payload = $request->all();
|
$payload = $request->all();
|
||||||
|
$value = data_get($payload, 'entry.0.changes.0.value', []);
|
||||||
|
$messages = data_get($value, 'messages', []);
|
||||||
|
$statuses = data_get($value, 'statuses', []);
|
||||||
|
|
||||||
try {
|
foreach ($messages as $message) {
|
||||||
$entry = $payload['entry'][0] ?? null;
|
$telefono = data_get($message, 'from');
|
||||||
$changes = $entry['changes'][0] ?? null;
|
$texto = data_get($message, 'text.body', '');
|
||||||
$value = $changes['value'] ?? null;
|
|
||||||
$message = $value['messages'][0] ?? null;
|
|
||||||
|
|
||||||
if (!$message) {
|
WhatsappLog::create([
|
||||||
return response()->json(['status' => 'ok']);
|
'telefono' => $telefono,
|
||||||
|
'tipo' => 'recibido',
|
||||||
|
'mensaje' => $texto,
|
||||||
|
'whatsapp_mensaje_id' => data_get($message, 'id'),
|
||||||
|
'payload' => $payload,
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (preg_match('/\b(STOP|cancelar)\b/i', $texto)) {
|
||||||
|
$whatsapp->procesarOptOut($telefono);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
$telefono = $message['from'] ?? null;
|
foreach ($statuses as $status) {
|
||||||
$tipo = $message['type'] ?? 'text';
|
$mensajeId = data_get($status, 'id');
|
||||||
$alumno = $this->alumnoPorTelefono($telefono);
|
$statusEnvio = data_get($status, 'status');
|
||||||
$alumnoId = $alumno?->id;
|
|
||||||
|
|
||||||
$logData = [
|
WhatsappLog::where('whatsapp_mensaje_id', $mensajeId)
|
||||||
'alumno_id' => $alumnoId,
|
->update(['status_envio' => $statusEnvio]);
|
||||||
'telefono' => $telefono,
|
|
||||||
'tipo' => 'recibido',
|
|
||||||
'payload' => $payload,
|
|
||||||
];
|
|
||||||
|
|
||||||
if ($tipo === 'text') {
|
|
||||||
$texto = $message['text']['body'] ?? '';
|
|
||||||
$logData['mensaje'] = $texto;
|
|
||||||
$logData['media_type'] = null;
|
|
||||||
|
|
||||||
if ($this->esOptOut($texto)) {
|
|
||||||
$whatsapp->procesarOptOut($telefono);
|
|
||||||
$logData['tipo'] = 'opt_out';
|
|
||||||
}
|
|
||||||
} elseif (in_array($tipo, ['image', 'video', 'audio', 'document'])) {
|
|
||||||
$mediaId = $message[$tipo]['id'] ?? null;
|
|
||||||
|
|
||||||
if ($mediaId) {
|
|
||||||
$ruta = $whatsapp->descargarMedia($mediaId);
|
|
||||||
$logData['mensaje'] = $ruta;
|
|
||||||
$logData['media_type'] = $tipo;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$log = WhatsappLog::create($logData);
|
|
||||||
|
|
||||||
if ($alumnoId) {
|
|
||||||
broadcast(new NuevoMensajeWhatsApp($log));
|
|
||||||
|
|
||||||
// Marcar como no leído para el badge
|
|
||||||
WhatsappLog::where('id', $log->id)->update(['leido' => false]);
|
|
||||||
}
|
|
||||||
} catch (\Throwable $e) {
|
|
||||||
Log::error('WhatsApp webhook error', ['error' => $e->getMessage(), 'payload' => $payload]);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return response()->json(['status' => 'ok']);
|
return response()->json(['status' => 'ok']);
|
||||||
}
|
}
|
||||||
|
|
||||||
private function alumnoPorTelefono(?string $telefono): ?Alumno
|
|
||||||
{
|
|
||||||
if (!$telefono) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
$limpio = preg_replace('/\D/', '', $telefono);
|
|
||||||
$sinCodigo = strlen($limpio) === 12 ? substr($limpio, 2) : $limpio;
|
|
||||||
|
|
||||||
return Alumno::whereHas('alumnos', function ($q) use ($limpio, $sinCodigo) {
|
|
||||||
$q->where('telefono', $limpio)->orWhere('telefono', $sinCodigo);
|
|
||||||
})->first();
|
|
||||||
}
|
|
||||||
|
|
||||||
private function esOptOut(string $texto): bool
|
|
||||||
{
|
|
||||||
$normalizado = strtolower(trim($texto));
|
|
||||||
|
|
||||||
foreach ($this->optOutKeywords as $keyword) {
|
|
||||||
if ($normalizado === $keyword || str_contains($normalizado, $keyword)) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -48,7 +48,8 @@ class EnviarPromocionJob implements ShouldQueue
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$mensajeId = $whatsapp->enviarPromocion($telefono, $this->promocion);
|
$nombre = $this->alumno->alumnos()->first()?->name ?? 'estudiante';
|
||||||
|
$mensajeId = $whatsapp->enviarPromocion($telefono, $this->promocion, $nombre);
|
||||||
} catch (\RuntimeException $e) {
|
} catch (\RuntimeException $e) {
|
||||||
WhatsappLog::create([
|
WhatsappLog::create([
|
||||||
'alumno_id' => $this->alumno->id,
|
'alumno_id' => $this->alumno->id,
|
||||||
|
|||||||
@@ -34,6 +34,12 @@ class Promocion extends Model
|
|||||||
|
|
||||||
public function imagenUrl(): ?string
|
public function imagenUrl(): ?string
|
||||||
{
|
{
|
||||||
return $this->imagen ? asset('storage/' . $this->imagen) : null;
|
if (!$this->imagen) return null;
|
||||||
|
|
||||||
|
if (str_starts_with($this->imagen, 'http')) {
|
||||||
|
return $this->imagen;
|
||||||
|
}
|
||||||
|
|
||||||
|
return asset('storage/' . $this->imagen);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,40 +28,71 @@ class WhatsAppService
|
|||||||
|
|
||||||
$this->token = $token;
|
$this->token = $token;
|
||||||
$this->phoneId = $phoneId;
|
$this->phoneId = $phoneId;
|
||||||
$this->apiUrl = "https://graph.facebook.com/v19.0/{$this->phoneId}/messages";
|
$this->apiUrl = "https://graph.facebook.com/v25.0/{$this->phoneId}/messages";
|
||||||
}
|
}
|
||||||
|
|
||||||
public function enviarTemplate(string $telefono, string $templateName, array $parametros = []): ?string
|
public function enviarTemplate(string $telefono, string $templateName, array $parametros = [], ?string $imagenUrl = null): ?string
|
||||||
{
|
{
|
||||||
$components = [];
|
$components = [];
|
||||||
|
|
||||||
if (!empty($parametros)) {
|
if ($imagenUrl) {
|
||||||
$components[] = [
|
$components[] = [
|
||||||
'type' => 'body',
|
'type' => 'header',
|
||||||
'parameters' => array_map(fn($p) => ['type' => 'text', 'text' => $p], $parametros),
|
'parameters' => [
|
||||||
];
|
[
|
||||||
}
|
'type' => 'image',
|
||||||
|
'image' => ['link' => $imagenUrl],
|
||||||
$response = Http::withToken($this->token)->post($this->apiUrl, [
|
]
|
||||||
'messaging_product' => 'whatsapp',
|
|
||||||
'to' => $this->normalizarTelefono($telefono),
|
|
||||||
'type' => 'template',
|
|
||||||
'template' => [
|
|
||||||
'name' => $templateName,
|
|
||||||
'language' => ['code' => 'es_MX'],
|
|
||||||
'components' => $components,
|
|
||||||
],
|
],
|
||||||
]);
|
];
|
||||||
|
|
||||||
if ($response->failed()) {
|
|
||||||
Log::error('WhatsApp template error', ['telefono' => $telefono, 'response' => $response->json()]);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return $response->json('messages.0.id');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public function enviarPromocion(string $telefono, Promocion $promocion): string
|
if (!empty($parametros)) {
|
||||||
|
$components[] = [
|
||||||
|
'type' => 'body',
|
||||||
|
'parameters' => array_map(fn($nombre, $valor) => [
|
||||||
|
'type' => 'text',
|
||||||
|
'text' => (string) $valor,
|
||||||
|
'parameter_name' => $nombre,
|
||||||
|
], array_keys($parametros), array_values($parametros)),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
Log::info('WhatsApp payload', [
|
||||||
|
'url' => $this->apiUrl,
|
||||||
|
'body' => [
|
||||||
|
'messaging_product' => 'whatsapp',
|
||||||
|
'to' => $this->normalizarTelefono($telefono),
|
||||||
|
'type' => 'template',
|
||||||
|
'template' => [
|
||||||
|
'name' => $templateName,
|
||||||
|
'language' => ['code' => 'es_MX'],
|
||||||
|
'components' => $components,
|
||||||
|
],
|
||||||
|
]
|
||||||
|
]);
|
||||||
|
|
||||||
|
|
||||||
|
$response = Http::withToken($this->token)->post($this->apiUrl, [
|
||||||
|
'messaging_product' => 'whatsapp',
|
||||||
|
'to' => $this->normalizarTelefono($telefono),
|
||||||
|
'type' => 'template',
|
||||||
|
'template' => [
|
||||||
|
'name' => $templateName,
|
||||||
|
'language' => ['code' => 'es_MX'],
|
||||||
|
'components' => $components,
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
if ($response->failed()) {
|
||||||
|
Log::error('WhatsApp template error', ['telefono' => $telefono, 'response' => $response->json(), 'body' => $response->body()]);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $response->json('messages.0.id');
|
||||||
|
}
|
||||||
|
|
||||||
|
/*public function enviarPromocion(string $telefono, Promocion $promocion): string
|
||||||
{
|
{
|
||||||
$payload = [
|
$payload = [
|
||||||
'messaging_product' => 'whatsapp',
|
'messaging_product' => 'whatsapp',
|
||||||
@@ -92,8 +123,33 @@ class WhatsAppService
|
|||||||
}
|
}
|
||||||
|
|
||||||
return $response->json('messages.0.id');
|
return $response->json('messages.0.id');
|
||||||
|
}*/
|
||||||
|
|
||||||
|
public function enviarPromocion(string $telefono, Promocion $promocion, string $nombre = ''): string
|
||||||
|
{
|
||||||
|
$codigo = 'PROMO-' . strtoupper(substr(md5($promocion->id . $telefono), 0, 6));
|
||||||
|
|
||||||
|
$mensajeId = $this->enviarTemplate(
|
||||||
|
$telefono,
|
||||||
|
'promocion_laravel',
|
||||||
|
[
|
||||||
|
'nombre' => $nombre ?: 'estudiante',
|
||||||
|
'fecha_inicio' => $promocion->fecha_inicio->locale('es')->isoFormat('D [de] MMMM'),
|
||||||
|
'fecha_fin' => $promocion->fecha_fin->locale('es')->isoFormat('D [de] MMMM'),
|
||||||
|
'codigo' => $codigo,
|
||||||
|
],
|
||||||
|
$promocion->imagenUrl()
|
||||||
|
);
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
if (!$mensajeId) {
|
||||||
|
throw new \RuntimeException('No se pudo enviar la plantilla');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return $mensajeId;
|
||||||
|
}
|
||||||
|
|
||||||
public function procesarOptOut(string $telefono): void
|
public function procesarOptOut(string $telefono): void
|
||||||
{
|
{
|
||||||
$normalizado = $this->normalizarTelefono($telefono);
|
$normalizado = $this->normalizarTelefono($telefono);
|
||||||
|
|||||||
Generated
+1
-1
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"name": "SistemaEducativoLaravel",
|
"name": "apps",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
|
|||||||
@@ -9,6 +9,9 @@
|
|||||||
|
|
||||||
@if ($prospecto)
|
@if ($prospecto)
|
||||||
|
|
||||||
|
|
||||||
|
<button onclick="" class="d-sm-inline-block btn btn-sm btn-danger shadow-sm"><i class="fas fa-delete fa-sm text-white-50"></i> Dar de baja</button>
|
||||||
|
|
||||||
<form wire:submit.prevent="{{ $prospecto ? 'actualizarProspecto' : 'guardarProspecto' }}" autocomplete="off">
|
<form wire:submit.prevent="{{ $prospecto ? 'actualizarProspecto' : 'guardarProspecto' }}" autocomplete="off">
|
||||||
|
|
||||||
<div class="row">
|
<div class="row">
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
|
|
||||||
{{-- Tarjetas de métricas --}}
|
{{-- Tarjetas de métricas --}}
|
||||||
<div class="row mb-4">
|
<div class="row mb-4">
|
||||||
<div class="col-xl-3 col-md-6 mb-4">
|
<div class="col-xl-2 col-md-4 mb-4">
|
||||||
<div class="card border-left-success shadow h-100 py-2">
|
<div class="card border-left-success shadow h-100 py-2">
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<div class="row no-gutters align-items-center">
|
<div class="row no-gutters align-items-center">
|
||||||
@@ -15,15 +15,41 @@
|
|||||||
<div class="text-xs font-weight-bold text-success text-uppercase mb-1">Enviados</div>
|
<div class="text-xs font-weight-bold text-success text-uppercase mb-1">Enviados</div>
|
||||||
<div class="h5 mb-0 font-weight-bold text-gray-800">{{ number_format($enviados) }}</div>
|
<div class="h5 mb-0 font-weight-bold text-gray-800">{{ number_format($enviados) }}</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-auto">
|
<div class="col-auto"><i class="fas fa-paper-plane fa-2x text-gray-300"></i></div>
|
||||||
<i class="fas fa-paper-plane fa-2x text-gray-300"></i>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="col-xl-3 col-md-6 mb-4">
|
<div class="col-xl-2 col-md-4 mb-4">
|
||||||
|
<div class="card border-left-primary shadow h-100 py-2">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="row no-gutters align-items-center">
|
||||||
|
<div class="col mr-2">
|
||||||
|
<div class="text-xs font-weight-bold text-primary text-uppercase mb-1">Entregados</div>
|
||||||
|
<div class="h5 mb-0 font-weight-bold text-gray-800">{{ number_format($entregados) }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-auto"><i class="fas fa-check-double fa-2x text-gray-300"></i></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-xl-2 col-md-4 mb-4">
|
||||||
|
<div class="card border-left-info shadow h-100 py-2" style="border-left-color:#00b2ff!important">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="row no-gutters align-items-center">
|
||||||
|
<div class="col mr-2">
|
||||||
|
<div class="text-xs font-weight-bold text-uppercase mb-1" style="color:#00b2ff">Leídos</div>
|
||||||
|
<div class="h5 mb-0 font-weight-bold text-gray-800">{{ number_format($leidos) }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-auto"><i class="fas fa-eye fa-2x text-gray-300"></i></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-xl-2 col-md-4 mb-4">
|
||||||
<div class="card border-left-info shadow h-100 py-2">
|
<div class="card border-left-info shadow h-100 py-2">
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<div class="row no-gutters align-items-center">
|
<div class="row no-gutters align-items-center">
|
||||||
@@ -31,15 +57,13 @@
|
|||||||
<div class="text-xs font-weight-bold text-info text-uppercase mb-1">Recibidos</div>
|
<div class="text-xs font-weight-bold text-info text-uppercase mb-1">Recibidos</div>
|
||||||
<div class="h5 mb-0 font-weight-bold text-gray-800">{{ number_format($recibidos) }}</div>
|
<div class="h5 mb-0 font-weight-bold text-gray-800">{{ number_format($recibidos) }}</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-auto">
|
<div class="col-auto"><i class="fas fa-inbox fa-2x text-gray-300"></i></div>
|
||||||
<i class="fas fa-inbox fa-2x text-gray-300"></i>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="col-xl-3 col-md-6 mb-4">
|
<div class="col-xl-2 col-md-4 mb-4">
|
||||||
<div class="card border-left-danger shadow h-100 py-2">
|
<div class="card border-left-danger shadow h-100 py-2">
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<div class="row no-gutters align-items-center">
|
<div class="row no-gutters align-items-center">
|
||||||
@@ -47,15 +71,13 @@
|
|||||||
<div class="text-xs font-weight-bold text-danger text-uppercase mb-1">Fallidos</div>
|
<div class="text-xs font-weight-bold text-danger text-uppercase mb-1">Fallidos</div>
|
||||||
<div class="h5 mb-0 font-weight-bold text-gray-800">{{ number_format($fallidos) }}</div>
|
<div class="h5 mb-0 font-weight-bold text-gray-800">{{ number_format($fallidos) }}</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-auto">
|
<div class="col-auto"><i class="fas fa-exclamation-triangle fa-2x text-gray-300"></i></div>
|
||||||
<i class="fas fa-exclamation-triangle fa-2x text-gray-300"></i>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="col-xl-3 col-md-6 mb-4">
|
<div class="col-xl-2 col-md-4 mb-4">
|
||||||
<div class="card border-left-warning shadow h-100 py-2">
|
<div class="card border-left-warning shadow h-100 py-2">
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<div class="row no-gutters align-items-center">
|
<div class="row no-gutters align-items-center">
|
||||||
@@ -63,15 +85,48 @@
|
|||||||
<div class="text-xs font-weight-bold text-warning text-uppercase mb-1">Opt-Outs</div>
|
<div class="text-xs font-weight-bold text-warning text-uppercase mb-1">Opt-Outs</div>
|
||||||
<div class="h5 mb-0 font-weight-bold text-gray-800">{{ number_format($optOuts) }}</div>
|
<div class="h5 mb-0 font-weight-bold text-gray-800">{{ number_format($optOuts) }}</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-auto">
|
<div class="col-auto"><i class="fas fa-ban fa-2x text-gray-300"></i></div>
|
||||||
<i class="fas fa-ban fa-2x text-gray-300"></i>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
@if($enviados > 0)
|
||||||
|
{{-- Barra de progreso del embudo de entrega --}}
|
||||||
|
<div class="card shadow mb-4">
|
||||||
|
<div class="card-header py-3">
|
||||||
|
<h6 class="m-0 font-weight-bold text-primary">
|
||||||
|
<i class="fas fa-funnel-dollar mr-1"></i> Embudo de entrega
|
||||||
|
</h6>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="mb-1 d-flex justify-content-between small font-weight-bold">
|
||||||
|
<span>Enviados</span><span>{{ $enviados }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="progress mb-3" style="height:20px">
|
||||||
|
<div class="progress-bar bg-success" style="width:100%">100%</div>
|
||||||
|
</div>
|
||||||
|
@if($entregados > 0)
|
||||||
|
<div class="mb-1 d-flex justify-content-between small font-weight-bold">
|
||||||
|
<span>Entregados</span><span>{{ $entregados }} ({{ round($entregados/$enviados*100) }}%)</span>
|
||||||
|
</div>
|
||||||
|
<div class="progress mb-3" style="height:20px">
|
||||||
|
<div class="progress-bar bg-primary" style="width:{{ round($entregados/$enviados*100) }}%">{{ round($entregados/$enviados*100) }}%</div>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
@if($leidos > 0)
|
||||||
|
<div class="mb-1 d-flex justify-content-between small font-weight-bold">
|
||||||
|
<span>Leídos</span><span>{{ $leidos }} ({{ round($leidos/$enviados*100) }}%)</span>
|
||||||
|
</div>
|
||||||
|
<div class="progress mb-1" style="height:20px">
|
||||||
|
<div class="progress-bar" style="width:{{ round($leidos/$enviados*100) }}%; background:#00b2ff">{{ round($leidos/$enviados*100) }}%</div>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
|
||||||
{{-- Filtros --}}
|
{{-- Filtros --}}
|
||||||
<div class="card shadow mb-4">
|
<div class="card shadow mb-4">
|
||||||
<div class="card-header py-3 d-flex align-items-center justify-content-between">
|
<div class="card-header py-3 d-flex align-items-center justify-content-between">
|
||||||
@@ -106,6 +161,15 @@
|
|||||||
@endforeach
|
@endforeach
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="form-group mr-3 mb-2">
|
||||||
|
<label class="mr-2 font-weight-bold">Tipo</label>
|
||||||
|
<select name="tipo" class="form-control form-control-sm">
|
||||||
|
<option value="">Todos</option>
|
||||||
|
@foreach(['enviado','recibido','error','opt_out','status_sent','status_delivered','status_read','status_failed'] as $t)
|
||||||
|
<option value="{{ $t }}" {{ request('tipo') === $t ? 'selected' : '' }}>{{ $t }}</option>
|
||||||
|
@endforeach
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
<div class="form-group mr-3 mb-2">
|
<div class="form-group mr-3 mb-2">
|
||||||
<label class="mr-2 font-weight-bold">Desde</label>
|
<label class="mr-2 font-weight-bold">Desde</label>
|
||||||
<input type="date" name="fecha_desde" class="form-control form-control-sm"
|
<input type="date" name="fecha_desde" class="form-control form-control-sm"
|
||||||
@@ -128,10 +192,11 @@
|
|||||||
|
|
||||||
{{-- Logs recientes --}}
|
{{-- Logs recientes --}}
|
||||||
<div class="card shadow mb-4">
|
<div class="card shadow mb-4">
|
||||||
<div class="card-header py-3">
|
<div class="card-header py-3 d-flex align-items-center justify-content-between">
|
||||||
<h6 class="m-0 font-weight-bold text-primary">
|
<h6 class="m-0 font-weight-bold text-primary">
|
||||||
<i class="fas fa-list mr-1"></i> Logs recientes
|
<i class="fas fa-list mr-1"></i> Logs recientes
|
||||||
</h6>
|
</h6>
|
||||||
|
<span class="small text-muted">Haz clic en <i class="fas fa-code"></i> para ver el payload raw del webhook</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<div class="table-responsive">
|
<div class="table-responsive">
|
||||||
@@ -141,29 +206,34 @@
|
|||||||
<th style="width:50px">#</th>
|
<th style="width:50px">#</th>
|
||||||
<th>Prospecto</th>
|
<th>Prospecto</th>
|
||||||
<th style="width:110px">Teléfono</th>
|
<th style="width:110px">Teléfono</th>
|
||||||
<th style="width:90px" class="text-center">Tipo</th>
|
<th style="width:120px" class="text-center">Tipo</th>
|
||||||
<th>Mensaje</th>
|
<th>Mensaje</th>
|
||||||
<th style="width:80px">Media</th>
|
<th style="width:80px">Media</th>
|
||||||
<th style="width:140px">Fecha</th>
|
<th style="width:140px">Fecha</th>
|
||||||
|
<th style="width:50px" class="text-center">JSON</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
@forelse($logsRecientes as $log)
|
@forelse($logsRecientes as $log)
|
||||||
@php
|
@php
|
||||||
$user = $log->alumno?->alumnos->first();
|
$user = $log->alumno?->alumnos->first();
|
||||||
$badge = match($log->tipo) {
|
$badge = match(true) {
|
||||||
'enviado' => 'success',
|
$log->tipo === 'enviado' => 'success',
|
||||||
'recibido' => 'info',
|
$log->tipo === 'recibido' => 'info',
|
||||||
'opt_out' => 'warning',
|
$log->tipo === 'opt_out' => 'warning',
|
||||||
'error' => 'danger',
|
$log->tipo === 'error' => 'danger',
|
||||||
default => 'secondary',
|
$log->tipo === 'status_delivered' => 'primary',
|
||||||
|
$log->tipo === 'status_read' => 'info',
|
||||||
|
$log->tipo === 'status_sent' => 'secondary',
|
||||||
|
$log->tipo === 'status_failed' => 'danger',
|
||||||
|
default => 'secondary',
|
||||||
};
|
};
|
||||||
@endphp
|
@endphp
|
||||||
<tr>
|
<tr>
|
||||||
<td>{{ $log->id }}</td>
|
<td>{{ $log->id }}</td>
|
||||||
<td>
|
<td>
|
||||||
@if($user)
|
@if($user)
|
||||||
{{ $user->name }} {{ $user->apellidoPaterno }}
|
{{ $user->name }} {{ $user->apellidoPaterno ?? '' }}
|
||||||
@else
|
@else
|
||||||
<span class="text-muted">—</span>
|
<span class="text-muted">—</span>
|
||||||
@endif
|
@endif
|
||||||
@@ -183,10 +253,22 @@
|
|||||||
@endif
|
@endif
|
||||||
</td>
|
</td>
|
||||||
<td class="small">{{ $log->created_at?->format('d/m/Y H:i') }}</td>
|
<td class="small">{{ $log->created_at?->format('d/m/Y H:i') }}</td>
|
||||||
|
<td class="text-center">
|
||||||
|
@if($log->payload)
|
||||||
|
<button type="button" class="btn btn-xs btn-outline-secondary btn-payload"
|
||||||
|
data-id="{{ $log->id }}"
|
||||||
|
data-payload="{{ htmlspecialchars(json_encode($log->payload, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE), ENT_QUOTES) }}"
|
||||||
|
title="Ver payload">
|
||||||
|
<i class="fas fa-code"></i>
|
||||||
|
</button>
|
||||||
|
@else
|
||||||
|
—
|
||||||
|
@endif
|
||||||
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@empty
|
@empty
|
||||||
<tr>
|
<tr>
|
||||||
<td colspan="7" class="text-center text-muted py-3">Sin registros.</td>
|
<td colspan="8" class="text-center text-muted py-3">Sin registros.</td>
|
||||||
</tr>
|
</tr>
|
||||||
@endforelse
|
@endforelse
|
||||||
</tbody>
|
</tbody>
|
||||||
@@ -199,4 +281,55 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{{-- Modal inspector de payload --}}
|
||||||
|
<div class="modal fade" id="payloadModal" tabindex="-1" role="dialog">
|
||||||
|
<div class="modal-dialog modal-lg modal-dialog-scrollable" role="document">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h5 class="modal-title">
|
||||||
|
<i class="fas fa-code mr-1"></i> Payload del webhook
|
||||||
|
<small class="text-muted ml-2" id="payloadLogId"></small>
|
||||||
|
</h5>
|
||||||
|
<button type="button" class="close" data-dismiss="modal">
|
||||||
|
<span>×</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body p-0">
|
||||||
|
<div class="d-flex justify-content-end p-2 border-bottom bg-light">
|
||||||
|
<button type="button" class="btn btn-xs btn-outline-secondary" id="btnCopyPayload">
|
||||||
|
<i class="fas fa-copy mr-1"></i> Copiar
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<pre id="payloadContent" class="m-0 p-3" style="background:#1e1e1e;color:#d4d4d4;font-size:12px;max-height:500px;overflow:auto;border-radius:0 0 4px 4px"></pre>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@push('scripts')
|
||||||
|
<script>
|
||||||
|
document.querySelectorAll('.btn-payload').forEach(function(btn) {
|
||||||
|
btn.addEventListener('click', function() {
|
||||||
|
var payload = this.getAttribute('data-payload');
|
||||||
|
var id = this.getAttribute('data-id');
|
||||||
|
document.getElementById('payloadLogId').textContent = '#' + id;
|
||||||
|
document.getElementById('payloadContent').textContent = payload;
|
||||||
|
$('#payloadModal').modal('show');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('btnCopyPayload').addEventListener('click', function() {
|
||||||
|
var text = document.getElementById('payloadContent').textContent;
|
||||||
|
navigator.clipboard.writeText(text).then(function() {
|
||||||
|
var btn = document.getElementById('btnCopyPayload');
|
||||||
|
btn.innerHTML = '<i class="fas fa-check mr-1"></i> Copiado';
|
||||||
|
setTimeout(function() {
|
||||||
|
btn.innerHTML = '<i class="fas fa-copy mr-1"></i> Copiar';
|
||||||
|
}, 2000);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
@endpush
|
||||||
|
|
||||||
@endsection
|
@endsection
|
||||||
|
|||||||
+11
-7
@@ -47,13 +47,22 @@ use App\Http\Controllers\UserController;
|
|||||||
use App\Models\Documento;
|
use App\Models\Documento;
|
||||||
use Illuminate\Support\Facades\Route;
|
use Illuminate\Support\Facades\Route;
|
||||||
use Laravel\Jetstream\Http\Controllers\Livewire\UserProfileController;
|
use Laravel\Jetstream\Http\Controllers\Livewire\UserProfileController;
|
||||||
|
|
||||||
Route::match(['GET','POST'],'/unsubscribe', [UnsubscribeController::class,'handle']);
|
Route::match(['GET','POST'],'/unsubscribe', [UnsubscribeController::class,'handle']);
|
||||||
|
|
||||||
Route::get('/',function(){return view('auth.login');});
|
Route::get('/',function(){return view('auth.login');});
|
||||||
Route::get('/prueba',function(){return view('prueba.index');});
|
Route::get('/prueba',function(){return view('prueba.index');});
|
||||||
Route::post('/stripe/webhook', [StripeWebhookController::class, 'handle']);
|
Route::post('/stripe/webhook', [StripeWebhookController::class, 'handle']);
|
||||||
|
|
||||||
|
Route::get('/webhook/whatsapp', [\App\Http\Controllers\WhatsAppWebhookController::class, 'verify']);
|
||||||
|
Route::post('/webhook/whatsapp', [\App\Http\Controllers\WhatsAppWebhookController::class, 'handle'])
|
||||||
|
->withoutMiddleware([\App\Http\Middleware\VerifyCsrfToken::class]);
|
||||||
|
|
||||||
|
Route::get('/storage/{path}', function (string $path) {
|
||||||
|
$fullPath = storage_path('app/public/' . $path);
|
||||||
|
abort_unless(file_exists($fullPath), 404);
|
||||||
|
return response()->file($fullPath);
|
||||||
|
})->where('path', '.*');
|
||||||
|
|
||||||
Route::middleware(['auth:sanctum',config('jetstream.auth_session'),'verified',
|
Route::middleware(['auth:sanctum',config('jetstream.auth_session'),'verified',
|
||||||
])->group(function () {
|
])->group(function () {
|
||||||
|
|
||||||
@@ -156,12 +165,7 @@ Route::middleware(['auth:sanctum',config('jetstream.auth_session'),'verified',
|
|||||||
Route::resource('/plantel',PlantelController::class);
|
Route::resource('/plantel',PlantelController::class);
|
||||||
Route::get('/registros', [CodeController::class,'registros' ])->name('registros');
|
Route::get('/registros', [CodeController::class,'registros' ])->name('registros');
|
||||||
Route::resource('/role',RoleController::class);
|
Route::resource('/role',RoleController::class);
|
||||||
Route::get('/storage/{path}', function ($path) { $fullPath = storage_path('app/public/' . $path);
|
|
||||||
if (!file_exists($fullPath)) {
|
|
||||||
abort(404);
|
|
||||||
}
|
|
||||||
return response()->file($fullPath);
|
|
||||||
})->where('path', '.*')->middleware('auth');
|
|
||||||
// Route::get('/test-chat', function () { broadcast(new MessageSent('Hola realtime ')); return 'Mensaje enviado';});
|
// Route::get('/test-chat', function () { broadcast(new MessageSent('Hola realtime ')); return 'Mensaje enviado';});
|
||||||
Route::resource('/turno',TurnoController::class);
|
Route::resource('/turno',TurnoController::class);
|
||||||
Route::resource('/user',UserController::class);
|
Route::resource('/user',UserController::class);
|
||||||
|
|||||||
Reference in New Issue
Block a user