Fix: socket permissions para nginx
This commit is contained in:
@@ -0,0 +1,41 @@
|
||||
<?php
|
||||
|
||||
namespace App\Events;
|
||||
|
||||
use App\Models\WhatsappLog;
|
||||
use Illuminate\Broadcasting\InteractsWithSockets;
|
||||
use Illuminate\Broadcasting\PrivateChannel;
|
||||
use Illuminate\Contracts\Broadcasting\ShouldBroadcastNow;
|
||||
use Illuminate\Foundation\Events\Dispatchable;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
|
||||
class NuevoMensajeWhatsApp implements ShouldBroadcastNow
|
||||
{
|
||||
use Dispatchable, InteractsWithSockets, SerializesModels;
|
||||
|
||||
public function __construct(public WhatsappLog $log) {}
|
||||
|
||||
public function broadcastOn(): array
|
||||
{
|
||||
if (!$this->log->alumno_id) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return [
|
||||
new PrivateChannel('whatsapp-chat.' . $this->log->alumno_id),
|
||||
];
|
||||
}
|
||||
|
||||
public function broadcastWith(): array
|
||||
{
|
||||
return [
|
||||
'id' => $this->log->id,
|
||||
'tipo' => $this->log->tipo,
|
||||
'mensaje' => $this->log->mensaje,
|
||||
'media_type' => $this->log->media_type,
|
||||
'alumno_id' => $this->log->alumno_id,
|
||||
'telefono' => $this->log->telefono,
|
||||
'created_at' => $this->log->created_at?->format('H:i'),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Jobs\EnviarPromocionJob;
|
||||
use App\Models\Alumno;
|
||||
use App\Models\Plantel;
|
||||
use App\Models\Promocion;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
class PromocionController extends Controller
|
||||
{
|
||||
public function index()
|
||||
{
|
||||
$plantelesIds = $this->plantelesDelUsuario()->pluck('id');
|
||||
|
||||
$promociones = Promocion::with('planteles')
|
||||
->whereHas('planteles', fn($q) => $q->whereIn('plantels.id', $plantelesIds))
|
||||
->latest()
|
||||
->paginate(15);
|
||||
|
||||
return view('promocion.index', compact('promociones'));
|
||||
}
|
||||
|
||||
public function create()
|
||||
{
|
||||
$planteles = $this->plantelesDelUsuario();
|
||||
return view('promocion.create', compact('planteles'));
|
||||
}
|
||||
|
||||
public function store(Request $request)
|
||||
{
|
||||
$data = $request->validate([
|
||||
'titulo' => 'required|string|max:200',
|
||||
'descripcion' => 'nullable|string|max:3000',
|
||||
'imagen' => 'nullable|image|max:10240',
|
||||
'fecha_inicio' => 'required|date',
|
||||
'fecha_fin' => 'required|date|after_or_equal:fecha_inicio',
|
||||
'status' => 'required|in:activo,inactivo',
|
||||
'plantels' => 'required|array|min:1',
|
||||
'plantels.*' => 'exists:plantels,id',
|
||||
]);
|
||||
|
||||
$this->validarAccesoPlanteles($request->plantels ?? []);
|
||||
|
||||
if ($request->hasFile('imagen')) {
|
||||
$data['imagen'] = $request->file('imagen')->store('promociones', 'public');
|
||||
}
|
||||
|
||||
unset($data['plantels']);
|
||||
$promocion = Promocion::create($data);
|
||||
$promocion->planteles()->sync($request->plantels);
|
||||
|
||||
return redirect()->route('promocion.index')->with('success', 'Promoción creada correctamente.');
|
||||
}
|
||||
|
||||
public function edit(Promocion $promocion)
|
||||
{
|
||||
$this->autorizarPlantel($promocion);
|
||||
$planteles = $this->plantelesDelUsuario();
|
||||
$plantelesSeleccionados = $promocion->planteles->pluck('id')->toArray();
|
||||
return view('promocion.edit', compact('promocion', 'planteles', 'plantelesSeleccionados'));
|
||||
}
|
||||
|
||||
public function update(Request $request, Promocion $promocion)
|
||||
{
|
||||
$this->autorizarPlantel($promocion);
|
||||
|
||||
$data = $request->validate([
|
||||
'titulo' => 'required|string|max:200',
|
||||
'descripcion' => 'nullable|string|max:3000',
|
||||
'imagen' => 'nullable|image|max:10240',
|
||||
'fecha_inicio' => 'required|date',
|
||||
'fecha_fin' => 'required|date|after_or_equal:fecha_inicio',
|
||||
'status' => 'required|in:activo,inactivo',
|
||||
'plantels' => 'required|array|min:1',
|
||||
'plantels.*' => 'exists:plantels,id',
|
||||
]);
|
||||
|
||||
$this->validarAccesoPlanteles($request->plantels ?? []);
|
||||
|
||||
if ($request->hasFile('imagen')) {
|
||||
if ($promocion->imagen) {
|
||||
Storage::disk('public')->delete($promocion->imagen);
|
||||
}
|
||||
$data['imagen'] = $request->file('imagen')->store('promociones', 'public');
|
||||
} elseif ($request->boolean('remove_imagen') && $promocion->imagen) {
|
||||
Storage::disk('public')->delete($promocion->imagen);
|
||||
$data['imagen'] = null;
|
||||
}
|
||||
|
||||
unset($data['plantels']);
|
||||
$promocion->update($data);
|
||||
$promocion->planteles()->sync($request->plantels);
|
||||
|
||||
return redirect()->route('promocion.index')->with('success', 'Promoción actualizada.');
|
||||
}
|
||||
|
||||
public function destroy(Promocion $promocion)
|
||||
{
|
||||
$this->autorizarPlantel($promocion);
|
||||
|
||||
if ($promocion->imagen) {
|
||||
Storage::disk('public')->delete($promocion->imagen);
|
||||
}
|
||||
|
||||
$promocion->delete();
|
||||
|
||||
return back()->with('success', 'Promoción eliminada.');
|
||||
}
|
||||
|
||||
public function toggleStatus(Promocion $promocion)
|
||||
{
|
||||
$this->autorizarPlantel($promocion);
|
||||
|
||||
$nuevoStatus = $promocion->status === 'activo' ? 'inactivo' : 'activo';
|
||||
$promocion->update(['status' => $nuevoStatus]);
|
||||
|
||||
return back()->with('success', $nuevoStatus === 'activo' ? 'Promoción activada.' : 'Promoción desactivada.');
|
||||
}
|
||||
|
||||
public function enviar(Promocion $promocion)
|
||||
{
|
||||
$this->autorizarPlantel($promocion);
|
||||
|
||||
$plantelesIds = $promocion->planteles->pluck('id');
|
||||
|
||||
$prospectos = Alumno::where('status', 1)
|
||||
->where('opt_out', false)
|
||||
->whereHas('alumnos.plantelUsuarios', function ($q) use ($plantelesIds) {
|
||||
$q->whereIn('plantels.id', $plantelesIds);
|
||||
})
|
||||
->get();
|
||||
|
||||
$despachados = 0;
|
||||
|
||||
foreach ($prospectos as $i => $alumno) {
|
||||
if (!$promocion->alumnos()->where('alumno_id', $alumno->id)->exists()) {
|
||||
$promocion->alumnos()->attach($alumno->id, ['status_envio' => 'pendiente']);
|
||||
}
|
||||
|
||||
EnviarPromocionJob::dispatch($promocion, $alumno)
|
||||
->delay(now()->addSeconds($i * 2));
|
||||
|
||||
$despachados++;
|
||||
}
|
||||
|
||||
if ($despachados === 0) {
|
||||
return back()->with('swal', [
|
||||
'icon' => 'warning',
|
||||
'title' => 'Sin destinatarios',
|
||||
'text' => 'No se encontraron prospectos activos para los planteles de esta promoción.',
|
||||
]);
|
||||
}
|
||||
|
||||
return back()->with('swal', [
|
||||
'icon' => 'success',
|
||||
'title' => '¡Enviado!',
|
||||
'text' => "Se programó el envío para {$despachados} prospecto(s).",
|
||||
]);
|
||||
}
|
||||
|
||||
private function plantelesDelUsuario()
|
||||
{
|
||||
return Auth::user()->plantelUsuarios;
|
||||
}
|
||||
|
||||
private function autorizarPlantel(Promocion $promocion): void
|
||||
{
|
||||
$ids = $this->plantelesDelUsuario()->pluck('id');
|
||||
$tieneAcceso = $promocion->planteles->pluck('id')->intersect($ids)->isNotEmpty();
|
||||
|
||||
if (!$tieneAcceso) {
|
||||
abort(403);
|
||||
}
|
||||
}
|
||||
|
||||
private function validarAccesoPlanteles(array $plantelIds): void
|
||||
{
|
||||
$permitidos = $this->plantelesDelUsuario()->pluck('id');
|
||||
|
||||
foreach ($plantelIds as $id) {
|
||||
if (!$permitidos->contains($id)) {
|
||||
abort(403, 'No tienes acceso a uno de los planteles seleccionados.');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\Plantel;
|
||||
use App\Models\Promocion;
|
||||
use App\Models\WhatsappLog;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
|
||||
class WhatsAppAnalyticsController extends Controller
|
||||
{
|
||||
public function index(Request $request)
|
||||
{
|
||||
$plantelesIds = Auth::user()->plantelUsuarios->pluck('id');
|
||||
$planteles = Auth::user()->plantelUsuarios;
|
||||
|
||||
$query = WhatsappLog::query();
|
||||
|
||||
if ($request->filled('plantel_id')) {
|
||||
$plantelId = (int) $request->plantel_id;
|
||||
$query->whereNotNull('alumno_id')
|
||||
->whereHas('alumno.alumnos.plantelUsuarios', function ($q) use ($plantelId) {
|
||||
$q->where('plantels.id', $plantelId);
|
||||
});
|
||||
}
|
||||
|
||||
if ($request->filled('promocion_id')) {
|
||||
$query->whereJsonContains('payload->promocion_id', (int) $request->promocion_id);
|
||||
}
|
||||
|
||||
if ($request->filled('fecha_desde')) {
|
||||
$query->where('created_at', '>=', $request->fecha_desde . ' 00:00:00');
|
||||
}
|
||||
|
||||
if ($request->filled('fecha_hasta')) {
|
||||
$query->where('created_at', '<=', $request->fecha_hasta . ' 23:59:59');
|
||||
}
|
||||
|
||||
$enviados = (clone $query)->where('tipo', 'enviado')->count();
|
||||
$fallidos = (clone $query)->where('tipo', 'error')->count();
|
||||
$optOuts = (clone $query)->where('tipo', 'opt_out')->count();
|
||||
$recibidos = (clone $query)->where('tipo', 'recibido')->count();
|
||||
|
||||
$logsRecientes = (clone $query)
|
||||
->with('alumno.alumnos')
|
||||
->latest('created_at')
|
||||
->paginate(20);
|
||||
|
||||
$promociones = Promocion::whereHas('planteles', function ($q) use ($plantelesIds) {
|
||||
$q->whereIn('plantels.id', $plantelesIds);
|
||||
})->latest()->get();
|
||||
|
||||
return view('promocion.analytics', compact(
|
||||
'enviados', 'fallidos', 'optOuts', 'recibidos',
|
||||
'logsRecientes', 'planteles', 'promociones'
|
||||
));
|
||||
}
|
||||
|
||||
public function stats(Request $request)
|
||||
{
|
||||
$query = WhatsappLog::query();
|
||||
|
||||
if ($request->filled('plantel_id')) {
|
||||
$plantelId = (int) $request->plantel_id;
|
||||
$query->whereNotNull('alumno_id')
|
||||
->whereHas('alumno.alumnos.plantelUsuarios', function ($q) use ($plantelId) {
|
||||
$q->where('plantels.id', $plantelId);
|
||||
});
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'enviados' => (clone $query)->where('tipo', 'enviado')->count(),
|
||||
'recibidos' => (clone $query)->where('tipo', 'recibido')->count(),
|
||||
'fallidos' => (clone $query)->where('tipo', 'error')->count(),
|
||||
'opt_outs' => (clone $query)->where('tipo', 'opt_out')->count(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function logs(Request $request)
|
||||
{
|
||||
$query = WhatsappLog::with('alumno.alumnos')
|
||||
->orderByDesc('created_at');
|
||||
|
||||
if ($request->filled('fecha_desde')) {
|
||||
$query->where('created_at', '>=', $request->fecha_desde);
|
||||
}
|
||||
|
||||
if ($request->filled('fecha_hasta')) {
|
||||
$query->where('created_at', '<=', $request->fecha_hasta . ' 23:59:59');
|
||||
}
|
||||
|
||||
if ($request->filled('plantel_id')) {
|
||||
$plantelId = (int) $request->plantel_id;
|
||||
$query->whereNotNull('alumno_id')
|
||||
->whereHas('alumno.alumnos.plantelUsuarios', function ($q) use ($plantelId) {
|
||||
$q->where('plantels.id', $plantelId);
|
||||
});
|
||||
}
|
||||
|
||||
if ($request->filled('tipo')) {
|
||||
$query->where('tipo', $request->tipo);
|
||||
}
|
||||
|
||||
$logs = $query->paginate(50);
|
||||
|
||||
return response()->json($logs);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\Alumno;
|
||||
use App\Models\WhatsappLog;
|
||||
use App\Services\WhatsAppService;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
|
||||
class WhatsAppChatController extends Controller
|
||||
{
|
||||
public function index()
|
||||
{
|
||||
$plantelesIds = Auth::user()->plantelUsuarios->pluck('id');
|
||||
|
||||
// Prospectos del plantel que tienen historial de WhatsApp
|
||||
$prospectos = Alumno::whereHas('alumnos.plantelUsuarios', function ($q) use ($plantelesIds) {
|
||||
$q->whereIn('plantels.id', $plantelesIds);
|
||||
})
|
||||
->whereHas('whatsappLogs')
|
||||
->with(['alumnos' => fn($q) => $q->select('users.id', 'users.name', 'users.apellidoPaterno', 'users.telefono', 'users.profile_photo_path')])
|
||||
->get()
|
||||
->map(function (Alumno $alumno) {
|
||||
$ultimoMensaje = $alumno->whatsappLogs()->latest('created_at')->first();
|
||||
$noLeidos = $alumno->whatsappLogs()->where('leido', false)->where('tipo', 'recibido')->count();
|
||||
|
||||
return [
|
||||
'alumno' => $alumno,
|
||||
'user' => $alumno->alumnos->first(),
|
||||
'ultimo' => $ultimoMensaje,
|
||||
'no_leidos' => $noLeidos,
|
||||
];
|
||||
})
|
||||
->sortByDesc(fn($item) => optional($item['ultimo'])->created_at);
|
||||
|
||||
$totalNoLeidos = WhatsappLog::whereIn('alumno_id', $prospectos->pluck('alumno.id'))
|
||||
->where('leido', false)
|
||||
->where('tipo', 'recibido')
|
||||
->count();
|
||||
|
||||
return view('promocion.chat', compact('prospectos', 'totalNoLeidos'));
|
||||
}
|
||||
|
||||
public function show(Alumno $alumno)
|
||||
{
|
||||
$this->autorizarAlumno($alumno);
|
||||
|
||||
$logs = WhatsappLog::where('alumno_id', $alumno->id)
|
||||
->orderBy('created_at')
|
||||
->get();
|
||||
|
||||
// Marcar como leídos
|
||||
WhatsappLog::where('alumno_id', $alumno->id)
|
||||
->where('leido', false)
|
||||
->update(['leido' => true]);
|
||||
|
||||
$user = $alumno->alumnos()->first();
|
||||
|
||||
return response()->json([
|
||||
'alumno' => [
|
||||
'id' => $alumno->id,
|
||||
'nombre' => $user ? "{$user->name} {$user->apellidoPaterno}" : "Alumno #{$alumno->id}",
|
||||
'avatar' => $user?->profile_photo_url ?? null,
|
||||
'tel' => $alumno->telefonoWhatsapp(),
|
||||
],
|
||||
'logs' => $logs->map(fn($log) => [
|
||||
'id' => $log->id,
|
||||
'tipo' => $log->tipo,
|
||||
'mensaje' => $log->mensaje,
|
||||
'media_type' => $log->media_type,
|
||||
'created_at' => $log->created_at?->format('d/m/Y H:i'),
|
||||
]),
|
||||
]);
|
||||
}
|
||||
|
||||
public function send(Request $request, WhatsAppService $whatsapp)
|
||||
{
|
||||
$request->validate([
|
||||
'alumno_id' => 'required|exists:alumnos,id',
|
||||
'mensaje' => 'required|string|max:4096',
|
||||
]);
|
||||
|
||||
$alumno = Alumno::findOrFail($request->alumno_id);
|
||||
$this->autorizarAlumno($alumno);
|
||||
|
||||
if ($alumno->opt_out) {
|
||||
return response()->json(['error' => 'El prospecto solicitó no recibir mensajes (opt-out).'], 422);
|
||||
}
|
||||
|
||||
$telefono = $alumno->telefonoWhatsapp();
|
||||
|
||||
if (!$telefono) {
|
||||
return response()->json(['error' => 'El prospecto no tiene teléfono registrado.'], 422);
|
||||
}
|
||||
|
||||
$mensajeId = $whatsapp->enviarTexto($telefono, $request->mensaje);
|
||||
|
||||
$log = WhatsappLog::create([
|
||||
'alumno_id' => $alumno->id,
|
||||
'telefono' => $telefono,
|
||||
'tipo' => 'enviado',
|
||||
'mensaje' => $request->mensaje,
|
||||
'payload' => ['enviado_por' => Auth::id(), 'whatsapp_id' => $mensajeId],
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
'id' => $log->id,
|
||||
'tipo' => $log->tipo,
|
||||
'mensaje' => $log->mensaje,
|
||||
'created_at' => $log->created_at->format('d/m/Y H:i'),
|
||||
]);
|
||||
}
|
||||
|
||||
private function autorizarAlumno(Alumno $alumno): void
|
||||
{
|
||||
$plantelesIds = Auth::user()->plantelUsuarios->pluck('id');
|
||||
|
||||
$tiene = $alumno->alumnos()
|
||||
->whereHas('plantelUsuarios', fn($q) => $q->whereIn('plantels.id', $plantelesIds))
|
||||
->exists();
|
||||
|
||||
if (!$tiene) {
|
||||
abort(403);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Events\NuevoMensajeWhatsApp;
|
||||
use App\Models\Alumno;
|
||||
use App\Models\WhatsappLog;
|
||||
use App\Services\WhatsAppService;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class WhatsAppWebhookController extends Controller
|
||||
{
|
||||
private array $optOutKeywords = ['stop', 'no', 'cancelar', 'detener', 'baja', 'salir'];
|
||||
|
||||
public function verify(Request $request)
|
||||
{
|
||||
$verifyToken = config('services.whatsapp.verify_token');
|
||||
|
||||
if (
|
||||
$request->get('hub_mode') === 'subscribe' &&
|
||||
$request->get('hub_verify_token') === $verifyToken
|
||||
) {
|
||||
return response($request->get('hub_challenge'), 200);
|
||||
}
|
||||
|
||||
return response('Forbidden', 403);
|
||||
}
|
||||
|
||||
public function handle(Request $request, WhatsAppService $whatsapp)
|
||||
{
|
||||
$payload = $request->all();
|
||||
|
||||
try {
|
||||
$entry = $payload['entry'][0] ?? null;
|
||||
$changes = $entry['changes'][0] ?? null;
|
||||
$value = $changes['value'] ?? null;
|
||||
$message = $value['messages'][0] ?? null;
|
||||
|
||||
if (!$message) {
|
||||
return response()->json(['status' => 'ok']);
|
||||
}
|
||||
|
||||
$telefono = $message['from'] ?? null;
|
||||
$tipo = $message['type'] ?? 'text';
|
||||
$alumno = $this->alumnoPorTelefono($telefono);
|
||||
$alumnoId = $alumno?->id;
|
||||
|
||||
$logData = [
|
||||
'alumno_id' => $alumnoId,
|
||||
'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']);
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
<?php
|
||||
|
||||
namespace App\Jobs;
|
||||
|
||||
use App\Models\Alumno;
|
||||
use App\Models\Promocion;
|
||||
use App\Models\WhatsappLog;
|
||||
use App\Services\WhatsAppService;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
|
||||
class EnviarPromocionJob implements ShouldQueue
|
||||
{
|
||||
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||
|
||||
public int $tries = 3;
|
||||
public int $backoff = 60;
|
||||
|
||||
public function __construct(
|
||||
public Promocion $promocion,
|
||||
public Alumno $alumno
|
||||
) {}
|
||||
|
||||
public function handle(WhatsAppService $whatsapp): void
|
||||
{
|
||||
$this->alumno->refresh();
|
||||
|
||||
if ($this->alumno->opt_out) {
|
||||
return;
|
||||
}
|
||||
|
||||
$telefono = $this->alumno->telefonoWhatsapp();
|
||||
|
||||
if (!$telefono) {
|
||||
WhatsappLog::create([
|
||||
'alumno_id' => $this->alumno->id,
|
||||
'telefono' => 'sin_telefono',
|
||||
'tipo' => 'error',
|
||||
'mensaje' => 'Alumno sin teléfono registrado',
|
||||
'payload' => ['promocion_id' => $this->promocion->id],
|
||||
]);
|
||||
|
||||
$this->actualizarPivot('fallido', null);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$mensajeId = $whatsapp->enviarPromocion($telefono, $this->promocion);
|
||||
} catch (\RuntimeException $e) {
|
||||
WhatsappLog::create([
|
||||
'alumno_id' => $this->alumno->id,
|
||||
'telefono' => $telefono,
|
||||
'tipo' => 'error',
|
||||
'mensaje' => $e->getMessage(),
|
||||
'payload' => ['promocion_id' => $this->promocion->id],
|
||||
]);
|
||||
|
||||
$this->actualizarPivot('fallido', null);
|
||||
return;
|
||||
}
|
||||
|
||||
WhatsappLog::create([
|
||||
'alumno_id' => $this->alumno->id,
|
||||
'telefono' => $telefono,
|
||||
'tipo' => 'enviado',
|
||||
'mensaje' => $this->promocion->titulo,
|
||||
'payload' => ['promocion_id' => $this->promocion->id, 'whatsapp_id' => $mensajeId],
|
||||
]);
|
||||
|
||||
$this->actualizarPivot('enviado', $mensajeId);
|
||||
}
|
||||
|
||||
private function actualizarPivot(string $status, ?string $mensajeId): void
|
||||
{
|
||||
$this->promocion->alumnos()->updateExistingPivot($this->alumno->id, [
|
||||
'status_envio' => $status,
|
||||
'enviado_at' => now(),
|
||||
'whatsapp_mensaje_id' => $mensajeId,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -5,11 +5,17 @@ namespace App\Models;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
|
||||
class Alumno extends Model
|
||||
{
|
||||
protected $guarded = [];
|
||||
|
||||
protected $casts = [
|
||||
'opt_out' => 'boolean',
|
||||
'opt_out_at' => 'datetime',
|
||||
];
|
||||
|
||||
public function existe($plantelId): bool
|
||||
{
|
||||
return $this->plantelUsuarios()
|
||||
@@ -52,4 +58,21 @@ class Alumno extends Model
|
||||
return $this->hasMany(Documento::class);
|
||||
}
|
||||
|
||||
public function promociones(): BelongsToMany
|
||||
{
|
||||
return $this->belongsToMany(Promocion::class, 'alumno_promocion')
|
||||
->withPivot(['enviado_at', 'status_envio', 'whatsapp_mensaje_id']);
|
||||
}
|
||||
|
||||
public function whatsappLogs(): HasMany
|
||||
{
|
||||
return $this->hasMany(WhatsappLog::class);
|
||||
}
|
||||
|
||||
public function telefonoWhatsapp(): ?string
|
||||
{
|
||||
$user = $this->alumnos()->first();
|
||||
return $user?->telefono;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
||||
|
||||
class Promocion extends Model
|
||||
{
|
||||
protected $table = 'promociones';
|
||||
|
||||
protected $guarded = [];
|
||||
|
||||
protected $casts = [
|
||||
'fecha_inicio' => 'date',
|
||||
'fecha_fin' => 'date',
|
||||
];
|
||||
|
||||
public function planteles(): BelongsToMany
|
||||
{
|
||||
return $this->belongsToMany(Plantel::class, 'plantel_promocion');
|
||||
}
|
||||
|
||||
public function alumnos(): BelongsToMany
|
||||
{
|
||||
return $this->belongsToMany(Alumno::class, 'alumno_promocion')
|
||||
->withPivot(['enviado_at', 'status_envio', 'whatsapp_mensaje_id']);
|
||||
}
|
||||
|
||||
public function isActiva(): bool
|
||||
{
|
||||
return $this->status === 'activo';
|
||||
}
|
||||
|
||||
public function imagenUrl(): ?string
|
||||
{
|
||||
return $this->imagen ? asset('storage/' . $this->imagen) : null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class WhatsappLog extends Model
|
||||
{
|
||||
public $timestamps = false;
|
||||
|
||||
protected $guarded = [];
|
||||
|
||||
protected $casts = [
|
||||
'payload' => 'array',
|
||||
'leido' => 'boolean',
|
||||
'created_at' => 'datetime',
|
||||
];
|
||||
|
||||
public function alumno(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Alumno::class);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\Alumno;
|
||||
use App\Models\Promocion;
|
||||
use App\Models\WhatsappLog;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
class WhatsAppService
|
||||
{
|
||||
private string $token;
|
||||
private string $phoneId;
|
||||
private string $apiUrl;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$token = config('services.whatsapp.token');
|
||||
$phoneId = config('services.whatsapp.phone_id');
|
||||
|
||||
if (!$token || !$phoneId) {
|
||||
throw new \RuntimeException(
|
||||
'WhatsApp no configurado. Agrega WHATSAPP_TOKEN y WHATSAPP_PHONE_ID en el archivo .env'
|
||||
);
|
||||
}
|
||||
|
||||
$this->token = $token;
|
||||
$this->phoneId = $phoneId;
|
||||
$this->apiUrl = "https://graph.facebook.com/v19.0/{$this->phoneId}/messages";
|
||||
}
|
||||
|
||||
public function enviarTemplate(string $telefono, string $templateName, array $parametros = []): ?string
|
||||
{
|
||||
$components = [];
|
||||
|
||||
if (!empty($parametros)) {
|
||||
$components[] = [
|
||||
'type' => 'body',
|
||||
'parameters' => array_map(fn($p) => ['type' => 'text', 'text' => $p], $parametros),
|
||||
];
|
||||
}
|
||||
|
||||
$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
|
||||
{
|
||||
$payload = [
|
||||
'messaging_product' => 'whatsapp',
|
||||
'to' => $this->normalizarTelefono($telefono),
|
||||
];
|
||||
|
||||
if ($promocion->imagen) {
|
||||
$payload['type'] = 'image';
|
||||
$payload['image'] = [
|
||||
'link' => asset('storage/' . $promocion->imagen),
|
||||
'caption' => "{$promocion->titulo}\n\n{$promocion->descripcion}",
|
||||
];
|
||||
} else {
|
||||
$payload['type'] = 'text';
|
||||
$payload['text'] = ['body' => "{$promocion->titulo}\n\n{$promocion->descripcion}"];
|
||||
}
|
||||
|
||||
$response = Http::withToken($this->token)->post($this->apiUrl, $payload);
|
||||
|
||||
if ($response->failed()) {
|
||||
Log::error('WhatsApp promocion error', [
|
||||
'telefono' => $telefono,
|
||||
'status' => $response->status(),
|
||||
'body' => $response->body(),
|
||||
]);
|
||||
|
||||
throw new \RuntimeException($response->body());
|
||||
}
|
||||
|
||||
return $response->json('messages.0.id');
|
||||
}
|
||||
|
||||
public function procesarOptOut(string $telefono): void
|
||||
{
|
||||
$normalizado = $this->normalizarTelefono($telefono);
|
||||
|
||||
Alumno::whereHas('alumnos', function ($q) use ($normalizado) {
|
||||
$q->where('telefono', $normalizado)
|
||||
->orWhere('telefono', ltrim($normalizado, '52'));
|
||||
})->each(function (Alumno $alumno) {
|
||||
$alumno->update([
|
||||
'opt_out' => true,
|
||||
'opt_out_at' => now(),
|
||||
]);
|
||||
});
|
||||
|
||||
WhatsappLog::create([
|
||||
'telefono' => $normalizado,
|
||||
'tipo' => 'opt_out',
|
||||
'mensaje' => 'Usuario solicitó opt-out',
|
||||
]);
|
||||
}
|
||||
|
||||
public function descargarMedia(string $mediaId): ?string
|
||||
{
|
||||
$infoResponse = Http::withToken($this->token)
|
||||
->get("https://graph.facebook.com/v19.0/{$mediaId}");
|
||||
|
||||
if ($infoResponse->failed()) {
|
||||
Log::error('WhatsApp media info error', ['mediaId' => $mediaId]);
|
||||
return null;
|
||||
}
|
||||
|
||||
$mediaUrl = $infoResponse->json('url');
|
||||
$mimeType = $infoResponse->json('mime_type', 'application/octet-stream');
|
||||
$extension = $this->extensionFromMime($mimeType);
|
||||
|
||||
$fileResponse = Http::withToken($this->token)->get($mediaUrl);
|
||||
|
||||
if ($fileResponse->failed()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$filename = 'whatsapp_media/' . $mediaId . '.' . $extension;
|
||||
Storage::disk('public')->put($filename, $fileResponse->body());
|
||||
|
||||
return $filename;
|
||||
}
|
||||
|
||||
public function enviarTexto(string $telefono, string $texto): ?string
|
||||
{
|
||||
$response = Http::withToken($this->token)->post($this->apiUrl, [
|
||||
'messaging_product' => 'whatsapp',
|
||||
'to' => $this->normalizarTelefono($telefono),
|
||||
'type' => 'text',
|
||||
'text' => ['body' => $texto],
|
||||
]);
|
||||
|
||||
if ($response->failed()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $response->json('messages.0.id');
|
||||
}
|
||||
|
||||
private function normalizarTelefono(string $telefono): string
|
||||
{
|
||||
$limpio = preg_replace('/\D/', '', $telefono);
|
||||
|
||||
// Si ya tiene el prefijo 52 (12 dígitos), quítalo para re-agregarlo limpio
|
||||
if (str_starts_with($limpio, '52') && strlen($limpio) === 12) {
|
||||
$limpio = substr($limpio, 2);
|
||||
}
|
||||
|
||||
// Siempre anteponer el código de país México
|
||||
return '52' . $limpio;
|
||||
}
|
||||
|
||||
private function extensionFromMime(string $mimeType): string
|
||||
{
|
||||
return match (true) {
|
||||
str_contains($mimeType, 'jpeg') => 'jpg',
|
||||
str_contains($mimeType, 'png') => 'png',
|
||||
str_contains($mimeType, 'gif') => 'gif',
|
||||
str_contains($mimeType, 'webp') => 'webp',
|
||||
str_contains($mimeType, 'mp4') => 'mp4',
|
||||
str_contains($mimeType, 'ogg') => 'ogg',
|
||||
str_contains($mimeType, 'mpeg') => 'mp3',
|
||||
str_contains($mimeType, 'pdf') => 'pdf',
|
||||
str_contains($mimeType, 'msword') => 'doc',
|
||||
str_contains($mimeType, 'spreadsheet')=> 'xlsx',
|
||||
default => 'bin',
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -45,4 +45,11 @@ return [
|
||||
'key' => env('GOOGLE_MAPS_API_KEY'),
|
||||
],
|
||||
|
||||
'whatsapp' => [
|
||||
'token' => env('WHATSAPP_TOKEN'),
|
||||
'phone_id' => env('WHATSAPP_PHONE_ID'),
|
||||
'business_id' => env('WHATSAPP_BUSINESS_ID'),
|
||||
'verify_token' => env('WHATSAPP_VERIFY_TOKEN'),
|
||||
],
|
||||
|
||||
];
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('promociones', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->string('titulo');
|
||||
$table->text('descripcion')->nullable();
|
||||
$table->string('imagen')->nullable();
|
||||
$table->date('fecha_inicio');
|
||||
$table->date('fecha_fin');
|
||||
$table->enum('status', ['activo', 'inactivo'])->default('activo');
|
||||
$table->foreignId('plantel_id')->constrained('plantels')->cascadeOnDelete();
|
||||
$table->timestamps();
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('promociones');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,25 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('alumno_promocion', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('alumno_id')->constrained('alumnos')->cascadeOnDelete();
|
||||
$table->foreignId('promocion_id')->constrained('promociones')->cascadeOnDelete();
|
||||
$table->timestamp('enviado_at')->nullable();
|
||||
$table->enum('status_envio', ['pendiente', 'enviado', 'fallido'])->default('pendiente');
|
||||
$table->string('whatsapp_mensaje_id')->nullable();
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('alumno_promocion');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('whatsapp_logs', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('alumno_id')->nullable()->constrained('alumnos')->nullOnDelete();
|
||||
$table->string('telefono', 30);
|
||||
$table->enum('tipo', ['enviado', 'recibido', 'opt_out', 'error']);
|
||||
$table->text('mensaje')->nullable();
|
||||
$table->string('media_type')->nullable();
|
||||
$table->json('payload')->nullable();
|
||||
$table->boolean('leido')->default(false);
|
||||
$table->timestamp('created_at')->useCurrent();
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('whatsapp_logs');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,23 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('alumnos', function (Blueprint $table) {
|
||||
$table->boolean('opt_out')->default(false)->after('status');
|
||||
$table->timestamp('opt_out_at')->nullable()->after('opt_out');
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('alumnos', function (Blueprint $table) {
|
||||
$table->dropColumn(['opt_out', 'opt_out_at']);
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,34 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
// Quitar la FK singular antes de modificar la tabla
|
||||
Schema::table('promociones', function (Blueprint $table) {
|
||||
$table->dropForeign(['plantel_id']);
|
||||
$table->dropColumn('plantel_id');
|
||||
});
|
||||
|
||||
Schema::create('plantel_promocion', function (Blueprint $table) {
|
||||
$table->unsignedBigInteger('plantel_id');
|
||||
$table->foreign('plantel_id')->references('id')->on('plantels')->cascadeOnDelete();
|
||||
$table->unsignedBigInteger('promocion_id');
|
||||
$table->foreign('promocion_id')->references('id')->on('promociones')->cascadeOnDelete();
|
||||
$table->primary(['plantel_id', 'promocion_id']);
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('plantel_promocion');
|
||||
|
||||
Schema::table('promociones', function (Blueprint $table) {
|
||||
$table->foreignId('plantel_id')->constrained('plantels')->cascadeOnDelete();
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -167,5 +167,13 @@ class RoleSeeder extends Seeder
|
||||
Permission::create(['name'=>'comunidad','description'=>'Comunidad'])->syncRoles([$role1]);
|
||||
Permission::create(['name'=>'amigos','description'=>'Amistades'])->syncRoles([$role1]);
|
||||
Permission::create(['name'=>'mensajes','description'=>'Mensajería'])->syncRoles([$role1]);
|
||||
|
||||
Permission::create(['name'=>'promocion.index','description'=>'promocion index'])->syncRoles([$role1]);
|
||||
Permission::create(['name'=>'promocion.create','description'=>'promocion crear'])->syncRoles([$role1]);
|
||||
Permission::create(['name'=>'promocion.edit','description'=>'promocion editar'])->syncRoles([$role1]);
|
||||
Permission::create(['name'=>'promocion.destroy','description'=>'promocion destuir'])->syncRoles([$role1]);
|
||||
Permission::create(['name'=>'promocion.enviar','description'=>'promocion enviar'])->syncRoles([$role1]);
|
||||
Permission::create(['name'=>'promocion.chat','description'=>'promocion chat'])->syncRoles([$role1]);
|
||||
Permission::create(['name'=>'promocion.analytics','description'=>'promocion analytics'])->syncRoles([$role1]);
|
||||
}
|
||||
}
|
||||
|
||||
+3
-3
@@ -2,11 +2,11 @@
|
||||
user = nobody
|
||||
group = nobody
|
||||
listen = /var/run/php-fpm.sock
|
||||
listen.owner = nobody
|
||||
listen.group = nobody
|
||||
listen.mode = 0660
|
||||
pm = dynamic
|
||||
pm.max_children = 10
|
||||
pm.start_servers = 2
|
||||
pm.min_spare_servers = 1
|
||||
pm.max_spare_servers = 3
|
||||
listen.owner = nginx
|
||||
listen.group = nginx
|
||||
listen.mode = 0660
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -9,7 +9,7 @@
|
||||
]
|
||||
},
|
||||
"resources/js/app.js": {
|
||||
"file": "assets/app-DAN06vjJ.js",
|
||||
"file": "assets/app-DUTi2uSh.js",
|
||||
"name": "app",
|
||||
"src": "resources/js/app.js",
|
||||
"isEntry": true
|
||||
|
||||
@@ -30,6 +30,7 @@
|
||||
<ul class="navbar-nav ml-auto">
|
||||
|
||||
|
||||
@can(['comunidad','amigos'])
|
||||
<!-- Nav Item - Search (XS) -->
|
||||
<li class="nav-item no-arrow d-sm-none position-relative">
|
||||
<a class="nav-link" href="#" id="mobile-search-toggle" role="button">
|
||||
@@ -52,6 +53,7 @@
|
||||
max-height:280px; overflow-y:auto"></div>
|
||||
</div>
|
||||
</li>
|
||||
@endcan
|
||||
|
||||
@can(['comunidad','amigos'])
|
||||
<!-- Nav Item - Notificaciones -->
|
||||
|
||||
@@ -210,6 +210,30 @@
|
||||
</li>
|
||||
@endcan
|
||||
|
||||
@can('promocion.index')
|
||||
<li class="nav-item">
|
||||
<a class="nav-link collapsed" href="#" data-toggle="collapse" data-target="#collapseWhatsApp"
|
||||
aria-expanded="false" aria-controls="collapseWhatsApp">
|
||||
<i class="fab fa-fw fa-whatsapp" style="color:#25D366"></i>
|
||||
<span>WhatsApp
|
||||
<span class="badge badge-danger badge-counter ml-1 d-none" id="wa-badge-menu">0</span>
|
||||
</span>
|
||||
</a>
|
||||
<div id="collapseWhatsApp" class="collapse" data-parent="#accordionSidebar">
|
||||
<div class="bg-white py-2 collapse-inner rounded">
|
||||
<h6 class="collapse-header">Marketing:</h6>
|
||||
<a class="collapse-item" href="{{ route('promocion.index') }}">Promociones</a>
|
||||
@can('promocion.chat')
|
||||
<a class="collapse-item" href="{{ route('promocion.chat') }}">Chat</a>
|
||||
@endcan
|
||||
@can('promocion.analytics')
|
||||
<a class="collapse-item" href="{{ route('promocion.analytics') }}">Analytics</a>
|
||||
@endcan
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
@endcan
|
||||
|
||||
<!-- Sidebar Toggler -->
|
||||
<div class="text-center d-none d-md-inline">
|
||||
<button class="rounded-circle border-0" id="sidebarToggle"></button>
|
||||
|
||||
@@ -0,0 +1,202 @@
|
||||
@extends('layouts.landing')
|
||||
|
||||
@section('title', 'Analytics WhatsApp')
|
||||
|
||||
@section('content')
|
||||
<div class="container-fluid">
|
||||
|
||||
{{-- Tarjetas de métricas --}}
|
||||
<div class="row mb-4">
|
||||
<div class="col-xl-3 col-md-6 mb-4">
|
||||
<div class="card border-left-success 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-success text-uppercase mb-1">Enviados</div>
|
||||
<div class="h5 mb-0 font-weight-bold text-gray-800">{{ number_format($enviados) }}</div>
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<i class="fas fa-paper-plane fa-2x text-gray-300"></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-xl-3 col-md-6 mb-4">
|
||||
<div class="card border-left-info 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-info text-uppercase mb-1">Recibidos</div>
|
||||
<div class="h5 mb-0 font-weight-bold text-gray-800">{{ number_format($recibidos) }}</div>
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<i class="fas fa-inbox fa-2x text-gray-300"></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-xl-3 col-md-6 mb-4">
|
||||
<div class="card border-left-danger 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-danger text-uppercase mb-1">Fallidos</div>
|
||||
<div class="h5 mb-0 font-weight-bold text-gray-800">{{ number_format($fallidos) }}</div>
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<i class="fas fa-exclamation-triangle fa-2x text-gray-300"></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-xl-3 col-md-6 mb-4">
|
||||
<div class="card border-left-warning 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-warning text-uppercase mb-1">Opt-Outs</div>
|
||||
<div class="h5 mb-0 font-weight-bold text-gray-800">{{ number_format($optOuts) }}</div>
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<i class="fas fa-ban fa-2x text-gray-300"></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Filtros --}}
|
||||
<div class="card shadow mb-4">
|
||||
<div class="card-header py-3 d-flex align-items-center justify-content-between">
|
||||
<h6 class="m-0 font-weight-bold text-primary">
|
||||
<i class="fas fa-filter mr-1"></i> Filtros
|
||||
</h6>
|
||||
<a href="{{ route('promocion.index') }}" class="btn btn-sm btn-secondary">
|
||||
<i class="fas fa-arrow-left mr-1"></i> Volver
|
||||
</a>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<form method="GET" action="{{ route('promocion.analytics') }}" class="form-inline flex-wrap">
|
||||
<div class="form-group mr-3 mb-2">
|
||||
<label class="mr-2 font-weight-bold">Plantel</label>
|
||||
<select name="plantel_id" class="form-control form-control-sm">
|
||||
<option value="">Todos</option>
|
||||
@foreach($planteles as $p)
|
||||
<option value="{{ $p->id }}" {{ request('plantel_id') == $p->id ? 'selected' : '' }}>
|
||||
{{ $p->name }}
|
||||
</option>
|
||||
@endforeach
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group mr-3 mb-2">
|
||||
<label class="mr-2 font-weight-bold">Promoción</label>
|
||||
<select name="promocion_id" class="form-control form-control-sm">
|
||||
<option value="">Todas</option>
|
||||
@foreach($promociones as $pr)
|
||||
<option value="{{ $pr->id }}" {{ request('promocion_id') == $pr->id ? 'selected' : '' }}>
|
||||
{{ $pr->titulo }}
|
||||
</option>
|
||||
@endforeach
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group mr-3 mb-2">
|
||||
<label class="mr-2 font-weight-bold">Desde</label>
|
||||
<input type="date" name="fecha_desde" class="form-control form-control-sm"
|
||||
value="{{ request('fecha_desde') }}">
|
||||
</div>
|
||||
<div class="form-group mr-3 mb-2">
|
||||
<label class="mr-2 font-weight-bold">Hasta</label>
|
||||
<input type="date" name="fecha_hasta" class="form-control form-control-sm"
|
||||
value="{{ request('fecha_hasta') }}">
|
||||
</div>
|
||||
<button type="submit" class="btn btn-sm btn-primary mb-2 mr-2">
|
||||
<i class="fas fa-search mr-1"></i> Filtrar
|
||||
</button>
|
||||
<a href="{{ route('promocion.analytics') }}" class="btn btn-sm btn-secondary mb-2">
|
||||
<i class="fas fa-times mr-1"></i> Limpiar
|
||||
</a>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Logs recientes --}}
|
||||
<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-list mr-1"></i> Logs recientes
|
||||
</h6>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-bordered table-hover table-sm">
|
||||
<thead class="thead-light">
|
||||
<tr>
|
||||
<th style="width:50px">#</th>
|
||||
<th>Prospecto</th>
|
||||
<th style="width:110px">Teléfono</th>
|
||||
<th style="width:90px" class="text-center">Tipo</th>
|
||||
<th>Mensaje</th>
|
||||
<th style="width:80px">Media</th>
|
||||
<th style="width:140px">Fecha</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@forelse($logsRecientes as $log)
|
||||
@php
|
||||
$user = $log->alumno?->alumnos->first();
|
||||
$badge = match($log->tipo) {
|
||||
'enviado' => 'success',
|
||||
'recibido' => 'info',
|
||||
'opt_out' => 'warning',
|
||||
'error' => 'danger',
|
||||
default => 'secondary',
|
||||
};
|
||||
@endphp
|
||||
<tr>
|
||||
<td>{{ $log->id }}</td>
|
||||
<td>
|
||||
@if($user)
|
||||
{{ $user->name }} {{ $user->apellidoPaterno }}
|
||||
@else
|
||||
<span class="text-muted">—</span>
|
||||
@endif
|
||||
</td>
|
||||
<td class="small">{{ $log->telefono }}</td>
|
||||
<td class="text-center">
|
||||
<span class="badge badge-{{ $badge }}">{{ $log->tipo }}</span>
|
||||
</td>
|
||||
<td class="small text-truncate" style="max-width:200px">
|
||||
{{ $log->mensaje ?? '—' }}
|
||||
</td>
|
||||
<td class="small text-center">
|
||||
@if($log->media_type)
|
||||
<span class="badge badge-light border">{{ $log->media_type }}</span>
|
||||
@else
|
||||
—
|
||||
@endif
|
||||
</td>
|
||||
<td class="small">{{ $log->created_at?->format('d/m/Y H:i') }}</td>
|
||||
</tr>
|
||||
@empty
|
||||
<tr>
|
||||
<td colspan="7" class="text-center text-muted py-3">Sin registros.</td>
|
||||
</tr>
|
||||
@endforelse
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="d-flex justify-content-center mt-3">
|
||||
{{ $logsRecientes->appends(request()->query())->links() }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
@endsection
|
||||
@@ -0,0 +1,281 @@
|
||||
@extends('layouts.landing')
|
||||
|
||||
@section('title', 'Chat WhatsApp')
|
||||
|
||||
@section('content')
|
||||
<div class="container-fluid">
|
||||
<div class="row" style="height:calc(100vh - 180px)">
|
||||
|
||||
{{-- Panel izquierdo: lista de prospectos --}}
|
||||
<div class="col-md-4 col-lg-3 pr-md-1 mb-3 mb-md-0">
|
||||
<div class="card shadow h-100 d-flex flex-column">
|
||||
<div class="card-header py-2 d-flex align-items-center justify-content-between">
|
||||
<h6 class="m-0 font-weight-bold text-primary">
|
||||
<i class="fab fa-whatsapp mr-1"></i> Conversaciones
|
||||
</h6>
|
||||
<a href="{{ route('promocion.index') }}" class="btn btn-sm btn-secondary">
|
||||
<i class="fas fa-arrow-left"></i>
|
||||
</a>
|
||||
</div>
|
||||
<div class="card-body p-0" style="overflow-y:auto; flex:1">
|
||||
@forelse($prospectos as $item)
|
||||
@php
|
||||
$alumno = $item['alumno'];
|
||||
$user = $item['user'];
|
||||
$ultimo = $item['ultimo'];
|
||||
@endphp
|
||||
<a href="#"
|
||||
class="prospecto-item d-flex align-items-center p-3 border-bottom text-dark text-decoration-none"
|
||||
data-id="{{ $alumno->id }}"
|
||||
data-nombre="{{ $user ? $user->name.' '.$user->apellidoPaterno : 'Alumno #'.$alumno->id }}"
|
||||
data-avatar="{{ $user?->profile_photo_url ?? '' }}">
|
||||
<div class="position-relative flex-shrink-0 mr-3">
|
||||
@if($user && $user->profile_photo_url)
|
||||
<img src="{{ $user->profile_photo_url }}"
|
||||
class="rounded-circle" width="40" height="40"
|
||||
style="object-fit:cover" alt="">
|
||||
@else
|
||||
<div class="rounded-circle bg-secondary d-flex align-items-center justify-content-center"
|
||||
style="width:40px;height:40px">
|
||||
<i class="fas fa-user text-white small"></i>
|
||||
</div>
|
||||
@endif
|
||||
@if($item['no_leidos'] > 0)
|
||||
<span class="badge badge-danger badge-counter position-absolute"
|
||||
style="top:-4px;right:-6px;min-width:18px"
|
||||
id="badge-{{ $alumno->id }}">
|
||||
{{ $item['no_leidos'] }}
|
||||
</span>
|
||||
@else
|
||||
<span class="badge badge-danger badge-counter d-none position-absolute"
|
||||
style="top:-4px;right:-6px;min-width:18px"
|
||||
id="badge-{{ $alumno->id }}">0</span>
|
||||
@endif
|
||||
</div>
|
||||
<div style="min-width:0;flex:1">
|
||||
<div class="font-weight-bold small text-truncate">
|
||||
{{ $user ? $user->name.' '.$user->apellidoPaterno : 'Alumno #'.$alumno->id }}
|
||||
</div>
|
||||
<div class="small text-muted text-truncate">
|
||||
{{ $ultimo?->mensaje ?? 'Sin mensajes' }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="small text-muted ml-2 flex-shrink-0">
|
||||
{{ $ultimo?->created_at?->format('H:i') ?? '' }}
|
||||
</div>
|
||||
</a>
|
||||
@empty
|
||||
<div class="text-center text-muted py-5">
|
||||
<i class="fab fa-whatsapp fa-3x mb-2 d-block text-success"></i>
|
||||
Sin conversaciones aún.
|
||||
</div>
|
||||
@endforelse
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Panel derecho: historial + envío --}}
|
||||
<div class="col-md-8 col-lg-9 pl-md-1">
|
||||
<div class="card shadow h-100 d-flex flex-column">
|
||||
{{-- Header del chat --}}
|
||||
<div class="card-header py-2 d-flex align-items-center" id="chatHeader" style="display:none!important">
|
||||
<div class="rounded-circle bg-secondary d-flex align-items-center justify-content-center mr-3"
|
||||
style="width:36px;height:36px;overflow:hidden" id="chatAvatar">
|
||||
<i class="fas fa-user text-white small"></i>
|
||||
</div>
|
||||
<div>
|
||||
<div class="font-weight-bold" id="chatNombre">—</div>
|
||||
<div class="small text-muted" id="chatTel"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Estado vacío --}}
|
||||
<div class="card-body d-flex align-items-center justify-content-center" id="chatVacio">
|
||||
<div class="text-center text-muted">
|
||||
<i class="fab fa-whatsapp fa-4x text-success mb-3 d-block"></i>
|
||||
Selecciona un prospecto para ver la conversación
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Mensajes --}}
|
||||
<div class="card-body p-3 d-none" id="chatMensajes"
|
||||
style="flex:1;overflow-y:auto;background:#ece5dd;">
|
||||
</div>
|
||||
|
||||
{{-- Input de envío --}}
|
||||
<div class="card-footer p-2 d-none" id="chatFooter">
|
||||
<form id="chatForm" class="d-flex">
|
||||
<input type="text" id="chatInput" class="form-control form-control-sm mr-2"
|
||||
placeholder="Escribe un mensaje..." autocomplete="off">
|
||||
<button type="submit" class="btn btn-sm btn-success">
|
||||
<i class="fas fa-paper-plane"></i>
|
||||
</button>
|
||||
</form>
|
||||
<div id="chatError" class="small text-danger mt-1 d-none"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
@endsection
|
||||
|
||||
@section('scripts')
|
||||
<script>
|
||||
(function () {
|
||||
let alumnoActual = null;
|
||||
let echoChannel = null;
|
||||
|
||||
const $vacio = document.getElementById('chatVacio');
|
||||
const $mensajes = document.getElementById('chatMensajes');
|
||||
const $footer = document.getElementById('chatFooter');
|
||||
const $header = document.getElementById('chatHeader');
|
||||
const $nombre = document.getElementById('chatNombre');
|
||||
const $tel = document.getElementById('chatTel');
|
||||
const $avatar = document.getElementById('chatAvatar');
|
||||
const $input = document.getElementById('chatInput');
|
||||
const $error = document.getElementById('chatError');
|
||||
const $form = document.getElementById('chatForm');
|
||||
|
||||
// Seleccionar prospecto
|
||||
document.querySelectorAll('.prospecto-item').forEach(el => {
|
||||
el.addEventListener('click', function (e) {
|
||||
e.preventDefault();
|
||||
document.querySelectorAll('.prospecto-item').forEach(x => x.classList.remove('bg-light'));
|
||||
this.classList.add('bg-light');
|
||||
|
||||
alumnoActual = this.dataset.id;
|
||||
const nombre = this.dataset.nombre;
|
||||
const avatar = this.dataset.avatar;
|
||||
|
||||
$nombre.textContent = nombre;
|
||||
|
||||
if (avatar) {
|
||||
$avatar.innerHTML = `<img src="${avatar}" class="rounded-circle" style="width:36px;height:36px;object-fit:cover" alt="">`;
|
||||
} else {
|
||||
$avatar.innerHTML = '<i class="fas fa-user text-white small"></i>';
|
||||
}
|
||||
|
||||
$header.style.removeProperty('display');
|
||||
$vacio.classList.add('d-none');
|
||||
$mensajes.classList.remove('d-none');
|
||||
$footer.classList.remove('d-none');
|
||||
$error.classList.add('d-none');
|
||||
|
||||
cargarMensajes(alumnoActual);
|
||||
suscribirCanal(alumnoActual);
|
||||
|
||||
// Limpiar badge
|
||||
const badge = document.getElementById('badge-' + alumnoActual);
|
||||
if (badge) badge.classList.add('d-none');
|
||||
});
|
||||
});
|
||||
|
||||
function cargarMensajes(id) {
|
||||
$mensajes.innerHTML = '<div class="text-center py-3"><i class="fas fa-spinner fa-spin"></i></div>';
|
||||
$tel.textContent = '';
|
||||
|
||||
axios.get(`/promociones/chat/${id}`)
|
||||
.then(res => {
|
||||
$tel.textContent = res.data.alumno.tel ?? '';
|
||||
renderMensajes(res.data.logs);
|
||||
})
|
||||
.catch(() => {
|
||||
$mensajes.innerHTML = '<div class="text-center text-danger py-3">Error al cargar mensajes.</div>';
|
||||
});
|
||||
}
|
||||
|
||||
function renderMensajes(logs) {
|
||||
$mensajes.innerHTML = '';
|
||||
logs.forEach(log => agregarMensaje(log));
|
||||
$mensajes.scrollTop = $mensajes.scrollHeight;
|
||||
}
|
||||
|
||||
function agregarMensaje(log) {
|
||||
const enviado = log.tipo === 'enviado';
|
||||
const wrapper = document.createElement('div');
|
||||
wrapper.className = `d-flex mb-2 ${enviado ? 'justify-content-end' : 'justify-content-start'}`;
|
||||
|
||||
let contenido = '';
|
||||
|
||||
if (log.media_type === 'image' && log.mensaje) {
|
||||
contenido = `<img src="/storage/${log.mensaje}" class="img-fluid rounded" style="max-width:200px" alt="imagen">`;
|
||||
} else if (log.media_type && log.mensaje) {
|
||||
const nombre = log.mensaje.split('/').pop();
|
||||
contenido = `<a href="/storage/${log.mensaje}" target="_blank" class="btn btn-sm btn-outline-secondary">
|
||||
<i class="fas fa-paperclip mr-1"></i>${nombre}
|
||||
</a>`;
|
||||
} else {
|
||||
contenido = escHTML(log.mensaje ?? '');
|
||||
}
|
||||
|
||||
wrapper.innerHTML = `
|
||||
<div class="px-3 py-2 rounded shadow-sm" style="max-width:70%;background:${enviado ? '#dcf8c6' : '#fff'};word-break:break-word">
|
||||
${contenido}
|
||||
<div class="text-right" style="font-size:.7rem;color:#999;margin-top:4px">${log.created_at}</div>
|
||||
</div>`;
|
||||
|
||||
$mensajes.appendChild(wrapper);
|
||||
$mensajes.scrollTop = $mensajes.scrollHeight;
|
||||
}
|
||||
|
||||
function suscribirCanal(id) {
|
||||
if (echoChannel) {
|
||||
window.Echo.leave(echoChannel);
|
||||
}
|
||||
|
||||
echoChannel = `whatsapp-chat.${id}`;
|
||||
|
||||
window.Echo.private(echoChannel)
|
||||
.listen('NuevoMensajeWhatsApp', (e) => {
|
||||
if (String(alumnoActual) === String(e.alumno_id)) {
|
||||
agregarMensaje({
|
||||
tipo: e.tipo,
|
||||
mensaje: e.mensaje,
|
||||
media_type: e.media_type,
|
||||
created_at: e.created_at,
|
||||
});
|
||||
} else {
|
||||
// Actualizar badge del prospecto en la lista
|
||||
const badge = document.getElementById('badge-' + e.alumno_id);
|
||||
if (badge) {
|
||||
const n = (parseInt(badge.textContent) || 0) + 1;
|
||||
badge.textContent = n;
|
||||
badge.classList.remove('d-none');
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
$form.addEventListener('submit', function (e) {
|
||||
e.preventDefault();
|
||||
const texto = $input.value.trim();
|
||||
if (!texto || !alumnoActual) return;
|
||||
|
||||
$input.disabled = true;
|
||||
$error.classList.add('d-none');
|
||||
|
||||
axios.post('/promociones/chat/send', { alumno_id: alumnoActual, mensaje: texto })
|
||||
.then(res => {
|
||||
$input.value = '';
|
||||
agregarMensaje(res.data);
|
||||
})
|
||||
.catch(err => {
|
||||
const msg = err.response?.data?.error ?? 'Error al enviar el mensaje.';
|
||||
$error.textContent = msg;
|
||||
$error.classList.remove('d-none');
|
||||
})
|
||||
.finally(() => {
|
||||
$input.disabled = false;
|
||||
$input.focus();
|
||||
});
|
||||
});
|
||||
|
||||
function escHTML(str) {
|
||||
const d = document.createElement('div');
|
||||
d.appendChild(document.createTextNode(str));
|
||||
return d.innerHTML;
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
@endsection
|
||||
@@ -0,0 +1,148 @@
|
||||
@extends('layouts.landing')
|
||||
|
||||
@section('title', 'Nueva Promoción')
|
||||
|
||||
@section('content')
|
||||
<div class="container-fluid">
|
||||
<div class="card shadow mb-4" style="max-width:760px; margin:auto">
|
||||
<div class="card-header py-3 d-flex align-items-center justify-content-between">
|
||||
<h6 class="m-0 font-weight-bold text-primary">
|
||||
<i class="fas fa-plus-circle mr-1"></i> Nueva Promoción
|
||||
</h6>
|
||||
<a href="{{ route('promocion.index') }}" class="btn btn-sm btn-secondary">
|
||||
<i class="fas fa-arrow-left mr-1"></i> Volver
|
||||
</a>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
|
||||
@if($errors->any())
|
||||
<div class="alert alert-danger">
|
||||
<ul class="mb-0">
|
||||
@foreach($errors->all() as $e)
|
||||
<li>{{ $e }}</li>
|
||||
@endforeach
|
||||
</ul>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<form action="{{ route('promocion.store') }}" method="POST" enctype="multipart/form-data">
|
||||
@csrf
|
||||
|
||||
<div class="form-group">
|
||||
<label class="font-weight-bold">Título <span class="text-danger">*</span></label>
|
||||
<input type="text" name="titulo" class="form-control @error('titulo') is-invalid @enderror"
|
||||
value="{{ old('titulo') }}" maxlength="200" required>
|
||||
@error('titulo')<div class="invalid-feedback">{{ $message }}</div>@enderror
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="font-weight-bold">Descripción</label>
|
||||
<textarea name="descripcion" class="form-control @error('descripcion') is-invalid @enderror"
|
||||
rows="4" maxlength="3000">{{ old('descripcion') }}</textarea>
|
||||
@error('descripcion')<div class="invalid-feedback">{{ $message }}</div>@enderror
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="font-weight-bold">Planteles <span class="text-danger">*</span></label>
|
||||
<div class="border rounded p-3 bg-light @error('plantels') border-danger @enderror">
|
||||
@foreach($planteles as $plantel)
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="checkbox"
|
||||
id="p-{{ $plantel->id }}"
|
||||
name="plantels[]" value="{{ $plantel->id }}"
|
||||
{{ in_array($plantel->id, old('plantels', [])) ? 'checked' : '' }}>
|
||||
<label class="form-check-label" for="p-{{ $plantel->id }}">
|
||||
{{ $plantel->name }}
|
||||
</label>
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
@error('plantels')<div class="text-danger small mt-1">{{ $message }}</div>@enderror
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<div class="form-group">
|
||||
<label class="font-weight-bold">Fecha inicio <span class="text-danger">*</span></label>
|
||||
<input type="date" name="fecha_inicio"
|
||||
class="form-control @error('fecha_inicio') is-invalid @enderror"
|
||||
value="{{ old('fecha_inicio') }}" required>
|
||||
@error('fecha_inicio')<div class="invalid-feedback">{{ $message }}</div>@enderror
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<div class="form-group">
|
||||
<label class="font-weight-bold">Fecha fin <span class="text-danger">*</span></label>
|
||||
<input type="date" name="fecha_fin"
|
||||
class="form-control @error('fecha_fin') is-invalid @enderror"
|
||||
value="{{ old('fecha_fin') }}" required>
|
||||
@error('fecha_fin')<div class="invalid-feedback">{{ $message }}</div>@enderror
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="font-weight-bold">Imagen</label>
|
||||
<div class="custom-file">
|
||||
<input type="file" class="custom-file-input @error('imagen') is-invalid @enderror"
|
||||
id="imagenInput" name="imagen" accept="image/*">
|
||||
<label class="custom-file-label" for="imagenInput">Seleccionar imagen (máx. 10 MB)</label>
|
||||
</div>
|
||||
@error('imagen')<div class="text-danger small mt-1">{{ $message }}</div>@enderror
|
||||
<div id="imagenPreview" class="mt-2 d-none">
|
||||
<img id="previewImg" src="" alt="Vista previa"
|
||||
class="img-fluid rounded" style="max-height:200px">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="font-weight-bold">Status</label>
|
||||
<div>
|
||||
<div class="form-check form-check-inline">
|
||||
<input class="form-check-input" type="radio" name="status" id="statusActivo"
|
||||
value="activo" {{ old('status', 'activo') === 'activo' ? 'checked' : '' }}>
|
||||
<label class="form-check-label" for="statusActivo">
|
||||
<span class="badge badge-success">Activo</span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="form-check form-check-inline">
|
||||
<input class="form-check-input" type="radio" name="status" id="statusInactivo"
|
||||
value="inactivo" {{ old('status') === 'inactivo' ? 'checked' : '' }}>
|
||||
<label class="form-check-label" for="statusInactivo">
|
||||
<span class="badge badge-secondary">Inactivo</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<hr>
|
||||
<div class="d-flex justify-content-end">
|
||||
<a href="{{ route('promocion.index') }}" class="btn btn-secondary mr-2">Cancelar</a>
|
||||
<button type="submit" class="btn btn-primary">
|
||||
<i class="fas fa-save mr-1"></i> Guardar
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@endsection
|
||||
|
||||
@section('scripts')
|
||||
<script>
|
||||
document.getElementById('imagenInput').addEventListener('change', function () {
|
||||
const file = this.files[0];
|
||||
this.nextElementSibling.textContent = file ? file.name : 'Seleccionar imagen (máx. 10 MB)';
|
||||
if (file) {
|
||||
const reader = new FileReader();
|
||||
reader.onload = e => {
|
||||
document.getElementById('previewImg').src = e.target.result;
|
||||
document.getElementById('imagenPreview').classList.remove('d-none');
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
} else {
|
||||
document.getElementById('imagenPreview').classList.add('d-none');
|
||||
}
|
||||
});
|
||||
</script>
|
||||
@endsection
|
||||
@@ -0,0 +1,168 @@
|
||||
@extends('layouts.landing')
|
||||
|
||||
@section('title', 'Editar Promoción')
|
||||
|
||||
@section('content')
|
||||
<div class="container-fluid">
|
||||
<div class="card shadow mb-4" style="max-width:760px; margin:auto">
|
||||
<div class="card-header py-3 d-flex align-items-center justify-content-between">
|
||||
<h6 class="m-0 font-weight-bold text-primary">
|
||||
<i class="fas fa-edit mr-1"></i> Editar Promoción
|
||||
</h6>
|
||||
<a href="{{ route('promocion.index') }}" class="btn btn-sm btn-secondary">
|
||||
<i class="fas fa-arrow-left mr-1"></i> Volver
|
||||
</a>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
|
||||
@if($errors->any())
|
||||
<div class="alert alert-danger">
|
||||
<ul class="mb-0">
|
||||
@foreach($errors->all() as $e)
|
||||
<li>{{ $e }}</li>
|
||||
@endforeach
|
||||
</ul>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<form action="{{ route('promocion.update', $promocion) }}" method="POST" enctype="multipart/form-data">
|
||||
@csrf
|
||||
@method('PUT')
|
||||
|
||||
<div class="form-group">
|
||||
<label class="font-weight-bold">Título <span class="text-danger">*</span></label>
|
||||
<input type="text" name="titulo" class="form-control @error('titulo') is-invalid @enderror"
|
||||
value="{{ old('titulo', $promocion->titulo) }}" maxlength="200" required>
|
||||
@error('titulo')<div class="invalid-feedback">{{ $message }}</div>@enderror
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="font-weight-bold">Descripción</label>
|
||||
<textarea name="descripcion" class="form-control @error('descripcion') is-invalid @enderror"
|
||||
rows="4" maxlength="3000">{{ old('descripcion', $promocion->descripcion) }}</textarea>
|
||||
@error('descripcion')<div class="invalid-feedback">{{ $message }}</div>@enderror
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="font-weight-bold">Planteles <span class="text-danger">*</span></label>
|
||||
<div class="border rounded p-3 bg-light @error('plantels') border-danger @enderror">
|
||||
@php $seleccionados = old('plantels', $plantelesSeleccionados ?? []); @endphp
|
||||
@foreach($planteles as $plantel)
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="checkbox"
|
||||
id="p-{{ $plantel->id }}"
|
||||
name="plantels[]" value="{{ $plantel->id }}"
|
||||
{{ in_array($plantel->id, $seleccionados) ? 'checked' : '' }}>
|
||||
<label class="form-check-label" for="p-{{ $plantel->id }}">
|
||||
{{ $plantel->name }}
|
||||
</label>
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
@error('plantels')<div class="text-danger small mt-1">{{ $message }}</div>@enderror
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<div class="form-group">
|
||||
<label class="font-weight-bold">Fecha inicio <span class="text-danger">*</span></label>
|
||||
<input type="date" name="fecha_inicio"
|
||||
class="form-control @error('fecha_inicio') is-invalid @enderror"
|
||||
value="{{ old('fecha_inicio', $promocion->fecha_inicio->format('Y-m-d')) }}" required>
|
||||
@error('fecha_inicio')<div class="invalid-feedback">{{ $message }}</div>@enderror
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<div class="form-group">
|
||||
<label class="font-weight-bold">Fecha fin <span class="text-danger">*</span></label>
|
||||
<input type="date" name="fecha_fin"
|
||||
class="form-control @error('fecha_fin') is-invalid @enderror"
|
||||
value="{{ old('fecha_fin', $promocion->fecha_fin->format('Y-m-d')) }}" required>
|
||||
@error('fecha_fin')<div class="invalid-feedback">{{ $message }}</div>@enderror
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Imagen actual --}}
|
||||
<div class="form-group">
|
||||
<label class="font-weight-bold">Imagen</label>
|
||||
|
||||
@if($promocion->imagen)
|
||||
<div class="mb-2">
|
||||
<img src="{{ asset('storage/'.$promocion->imagen) }}" alt="Imagen actual"
|
||||
class="img-thumbnail" style="max-height:150px">
|
||||
<div class="form-check mt-1">
|
||||
<input class="form-check-input" type="checkbox" name="remove_imagen"
|
||||
id="removeImagen" value="1">
|
||||
<label class="form-check-label text-danger small" for="removeImagen">
|
||||
Eliminar imagen actual
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<div class="custom-file">
|
||||
<input type="file" class="custom-file-input @error('imagen') is-invalid @enderror"
|
||||
id="imagenInput" name="imagen" accept="image/*">
|
||||
<label class="custom-file-label" for="imagenInput">
|
||||
{{ $promocion->imagen ? 'Cambiar imagen' : 'Seleccionar imagen (máx. 10 MB)' }}
|
||||
</label>
|
||||
</div>
|
||||
@error('imagen')<div class="text-danger small mt-1">{{ $message }}</div>@enderror
|
||||
<div id="imagenPreview" class="mt-2 d-none">
|
||||
<img id="previewImg" src="" alt="Vista previa"
|
||||
class="img-fluid rounded" style="max-height:200px">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="font-weight-bold">Status</label>
|
||||
<div>
|
||||
<div class="form-check form-check-inline">
|
||||
<input class="form-check-input" type="radio" name="status" id="statusActivo"
|
||||
value="activo" {{ old('status', $promocion->status) === 'activo' ? 'checked' : '' }}>
|
||||
<label class="form-check-label" for="statusActivo">
|
||||
<span class="badge badge-success">Activo</span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="form-check form-check-inline">
|
||||
<input class="form-check-input" type="radio" name="status" id="statusInactivo"
|
||||
value="inactivo" {{ old('status', $promocion->status) === 'inactivo' ? 'checked' : '' }}>
|
||||
<label class="form-check-label" for="statusInactivo">
|
||||
<span class="badge badge-secondary">Inactivo</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<hr>
|
||||
<div class="d-flex justify-content-end">
|
||||
<a href="{{ route('promocion.index') }}" class="btn btn-secondary mr-2">Cancelar</a>
|
||||
<button type="submit" class="btn btn-primary">
|
||||
<i class="fas fa-save mr-1"></i> Actualizar
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@endsection
|
||||
|
||||
@section('scripts')
|
||||
<script>
|
||||
document.getElementById('imagenInput').addEventListener('change', function () {
|
||||
const file = this.files[0];
|
||||
this.nextElementSibling.textContent = file ? file.name : 'Seleccionar imagen (máx. 10 MB)';
|
||||
if (file) {
|
||||
const reader = new FileReader();
|
||||
reader.onload = e => {
|
||||
document.getElementById('previewImg').src = e.target.result;
|
||||
document.getElementById('imagenPreview').classList.remove('d-none');
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
} else {
|
||||
document.getElementById('imagenPreview').classList.add('d-none');
|
||||
}
|
||||
});
|
||||
</script>
|
||||
@endsection
|
||||
@@ -0,0 +1,162 @@
|
||||
@extends('layouts.landing')
|
||||
|
||||
@section('title', 'Promociones')
|
||||
|
||||
@section('content')
|
||||
<div class="container-fluid">
|
||||
<div class="card shadow mb-4">
|
||||
<div class="card-header py-3 d-flex align-items-center justify-content-between">
|
||||
<h6 class="m-0 font-weight-bold text-primary">
|
||||
<i class="fab fa-whatsapp mr-1"></i> Promociones WhatsApp
|
||||
</h6>
|
||||
<div>
|
||||
<a href="{{ route('promocion.analytics') }}" class="btn btn-sm btn-info shadow-sm mr-1">
|
||||
<i class="fas fa-chart-bar fa-sm mr-1"></i> Analytics
|
||||
</a>
|
||||
<a href="{{ route('promocion.chat') }}" class="btn btn-sm btn-success shadow-sm mr-1">
|
||||
<i class="fas fa-comments fa-sm mr-1"></i> Chat
|
||||
</a>
|
||||
@can('promocion.create')
|
||||
<a href="{{ route('promocion.create') }}" class="btn btn-sm btn-primary shadow-sm">
|
||||
<i class="fas fa-plus fa-sm text-white-50 mr-1"></i> Nueva promoción
|
||||
</a>
|
||||
@endcan
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
|
||||
@if(session('success'))
|
||||
<div class="alert alert-success alert-dismissible fade show" role="alert">
|
||||
{{ session('success') }}
|
||||
<button type="button" class="close" data-dismiss="alert"><span>×</span></button>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<div class="table-responsive">
|
||||
<table class="table table-bordered table-hover" style="width:100%">
|
||||
<thead class="thead-light">
|
||||
<tr>
|
||||
<th style="width:50px">#</th>
|
||||
<th style="width:70px">Imagen</th>
|
||||
<th>Título</th>
|
||||
<th style="width:180px">Planteles</th>
|
||||
<th style="width:120px">Inicio</th>
|
||||
<th style="width:120px">Fin</th>
|
||||
<th style="width:90px" class="text-center">Status</th>
|
||||
<th style="width:160px" class="text-center">Acciones</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@forelse($promociones as $promo)
|
||||
<tr>
|
||||
<td>{{ $promo->id }}</td>
|
||||
<td class="text-center">
|
||||
@if($promo->imagen)
|
||||
<img src="{{ asset('storage/'.$promo->imagen) }}"
|
||||
alt="img" class="rounded" style="width:50px;height:50px;object-fit:cover">
|
||||
@else
|
||||
<span class="text-muted"><i class="fas fa-image"></i></span>
|
||||
@endif
|
||||
</td>
|
||||
<td>
|
||||
<strong>{{ $promo->titulo }}</strong>
|
||||
@if($promo->descripcion)
|
||||
<div class="small text-muted text-truncate" style="max-width:260px">
|
||||
{{ $promo->descripcion }}
|
||||
</div>
|
||||
@endif
|
||||
</td>
|
||||
<td>
|
||||
@forelse($promo->planteles as $p)
|
||||
<span class="badge badge-light border mr-1">{{ $p->name }}</span>
|
||||
@empty
|
||||
<span class="text-muted">—</span>
|
||||
@endforelse
|
||||
</td>
|
||||
<td>{{ $promo->fecha_inicio->format('d/m/Y') }}</td>
|
||||
<td>{{ $promo->fecha_fin->format('d/m/Y') }}</td>
|
||||
<td class="text-center">
|
||||
<form action="{{ route('promocion.toggle', $promo) }}" method="POST">
|
||||
@csrf
|
||||
@method('PATCH')
|
||||
<button type="submit"
|
||||
class="btn btn-sm {{ $promo->status === 'activo' ? 'btn-success' : 'btn-secondary' }}"
|
||||
title="{{ $promo->status === 'activo' ? 'Desactivar' : 'Activar' }}">
|
||||
<i class="fas {{ $promo->status === 'activo' ? 'fa-toggle-on' : 'fa-toggle-off' }}"></i>
|
||||
</button>
|
||||
</form>
|
||||
</td>
|
||||
<td class="text-center">
|
||||
@can('promocion.enviar')
|
||||
<form action="{{ route('promocion.enviar', $promo) }}" method="POST" class="d-inline enviar-promo-form">
|
||||
@csrf
|
||||
<button type="button" class="btn btn-sm btn-success btn-enviar-promo" title="Enviar por WhatsApp"
|
||||
data-titulo="{{ $promo->titulo }}">
|
||||
<i class="fab fa-whatsapp"></i>
|
||||
</button>
|
||||
</form>
|
||||
@endcan
|
||||
@can('promocion.edit')
|
||||
<a href="{{ route('promocion.edit', $promo) }}" class="btn btn-sm btn-primary">
|
||||
<i class="fas fa-edit"></i>
|
||||
</a>
|
||||
@endcan
|
||||
@can('promocion.destroy')
|
||||
<form action="{{ route('promocion.destroy', $promo) }}" method="POST" class="d-inline delete-form">
|
||||
@csrf
|
||||
@method('DELETE')
|
||||
<button type="submit" class="btn btn-sm btn-danger">
|
||||
<i class="fas fa-trash"></i>
|
||||
</button>
|
||||
</form>
|
||||
@endcan
|
||||
</td>
|
||||
</tr>
|
||||
@empty
|
||||
<tr>
|
||||
<td colspan="8" class="text-center text-muted py-4">
|
||||
<i class="fab fa-whatsapp fa-2x mb-2 d-block"></i>
|
||||
No hay promociones creadas.
|
||||
</td>
|
||||
</tr>
|
||||
@endforelse
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="d-flex justify-content-center mt-3">
|
||||
{{ $promociones->links() }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@endsection
|
||||
|
||||
@section('scripts')
|
||||
@include('layouts._partials.delete')
|
||||
<script>
|
||||
document.addEventListener('click', function (e) {
|
||||
const btn = e.target.closest('.btn-enviar-promo');
|
||||
if (!btn) return;
|
||||
|
||||
const form = btn.closest('.enviar-promo-form');
|
||||
const titulo = btn.dataset.titulo;
|
||||
|
||||
Swal.fire({
|
||||
title: '¿Enviar promoción?',
|
||||
html: `Se enviará <strong>${titulo}</strong> a todos los prospectos activos de los planteles asignados.`,
|
||||
icon: 'question',
|
||||
showCancelButton: true,
|
||||
confirmButtonColor: '#28a745',
|
||||
confirmButtonText: '<i class="fab fa-whatsapp mr-1"></i> Enviar',
|
||||
cancelButtonText: 'Cancelar',
|
||||
}).then(result => {
|
||||
if (result.isConfirmed) {
|
||||
btn.disabled = true;
|
||||
btn.innerHTML = '<span class="spinner-border spinner-border-sm"></span>';
|
||||
form.submit();
|
||||
}
|
||||
});
|
||||
});
|
||||
</script>
|
||||
@endsection
|
||||
@@ -52,6 +52,7 @@
|
||||
</div> --}}
|
||||
|
||||
<div class="mb-3">
|
||||
<label class="small mb-1">Planteles</label>
|
||||
@foreach ($plantels as $plantel)
|
||||
<div class="form-check">
|
||||
<input type="checkbox" class="form-check-input cb"
|
||||
|
||||
@@ -53,7 +53,7 @@
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="plantels[]">Seleccionar campus</label>
|
||||
<label for="plantels[]">Seleccionar plantel</label>
|
||||
|
||||
@foreach ($plantels as $plantel)
|
||||
<div class="form-check">
|
||||
|
||||
@@ -1,8 +1,20 @@
|
||||
<?php
|
||||
|
||||
use App\Http\Controllers\WhatsAppAnalyticsController;
|
||||
use App\Http\Controllers\WhatsAppWebhookController;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
|
||||
Route::get('/user', function (Request $request) {
|
||||
return $request->user();
|
||||
})->middleware('auth:sanctum');
|
||||
|
||||
// Webhook de Meta (sin auth)
|
||||
Route::get('/webhook/whatsapp', [WhatsAppWebhookController::class, 'verify']);
|
||||
Route::post('/webhook/whatsapp', [WhatsAppWebhookController::class, 'handle']);
|
||||
|
||||
// API para n8n (sin auth de usuario — proteger con token en n8n si se desea)
|
||||
Route::middleware('auth:sanctum')->group(function () {
|
||||
Route::get('/whatsapp/stats', [WhatsAppAnalyticsController::class, 'stats']);
|
||||
Route::get('/whatsapp/logs', [WhatsAppAnalyticsController::class, 'logs']);
|
||||
});
|
||||
|
||||
@@ -13,3 +13,17 @@ Broadcast::channel('chat.{receiverId}', function ($user, $receiverId) {
|
||||
Broadcast::channel('notifications.{userId}', function ($user, $userId) {
|
||||
return (int) $user->id === (int) $userId;
|
||||
});
|
||||
|
||||
// WhatsApp chat — solo usuarios del mismo plantel que el prospecto pueden escuchar
|
||||
Broadcast::channel('whatsapp-chat.{alumnoId}', function ($user, $alumnoId) {
|
||||
$alumno = \App\Models\Alumno::find($alumnoId);
|
||||
if (!$alumno) {
|
||||
return false;
|
||||
}
|
||||
$plantelesAlumno = $alumno->alumnos()
|
||||
->with('plantelUsuarios')
|
||||
->get()
|
||||
->flatMap(fn($u) => $u->plantelUsuarios->pluck('id'));
|
||||
$plantelesUsuario = $user->plantelUsuarios->pluck('id');
|
||||
return $plantelesAlumno->intersect($plantelesUsuario)->isNotEmpty();
|
||||
});
|
||||
|
||||
@@ -36,7 +36,10 @@ use App\Http\Controllers\PermissionController;
|
||||
use App\Http\Controllers\PlanController;
|
||||
use App\Http\Controllers\PlaneController;
|
||||
use App\Http\Controllers\PlantelController;
|
||||
use App\Http\Controllers\PromocionController;
|
||||
use App\Http\Controllers\RoleController;
|
||||
use App\Http\Controllers\WhatsAppAnalyticsController;
|
||||
use App\Http\Controllers\WhatsAppChatController;
|
||||
use App\Http\Controllers\StripeWebhookController;
|
||||
use App\Http\Controllers\TurnoController;
|
||||
use App\Http\Controllers\UnsubscribeController;
|
||||
@@ -165,4 +168,30 @@ Route::middleware(['auth:sanctum',config('jetstream.auth_session'),'verified',
|
||||
Route::resource('/mail',MailController::class);
|
||||
Route::get('/mailme/{user}',[MailController::class,'mailme'])->name('mailme');
|
||||
|
||||
// Promociones WhatsApp
|
||||
Route::resource('/promocion', PromocionController::class)->middleware([
|
||||
'index' => 'can:promocion.index',
|
||||
'create' => 'can:promocion.create',
|
||||
'store' => 'can:promocion.create',
|
||||
'edit' => 'can:promocion.edit',
|
||||
'update' => 'can:promocion.edit',
|
||||
'destroy' => 'can:promocion.destroy',
|
||||
]);
|
||||
Route::patch('/promocion/{promocion}/toggle', [PromocionController::class, 'toggleStatus'])
|
||||
->name('promocion.toggle')->middleware('can:promocion.edit');
|
||||
Route::post('/promocion/{promocion}/enviar', [PromocionController::class, 'enviar'])
|
||||
->name('promocion.enviar')->middleware('can:promocion.enviar');
|
||||
|
||||
// Chat WhatsApp
|
||||
Route::get('/promociones/chat', [WhatsAppChatController::class, 'index'])
|
||||
->name('promocion.chat')->middleware('can:promocion.chat');
|
||||
Route::get('/promociones/chat/{alumno}', [WhatsAppChatController::class, 'show'])
|
||||
->name('promocion.chat.show')->middleware('can:promocion.chat');
|
||||
Route::post('/promociones/chat/send', [WhatsAppChatController::class, 'send'])
|
||||
->name('promocion.chat.send')->middleware('can:promocion.chat');
|
||||
|
||||
// Analytics WhatsApp
|
||||
Route::get('/promociones/analytics', [WhatsAppAnalyticsController::class, 'index'])
|
||||
->name('promocion.analytics')->middleware('can:promocion.analytics');
|
||||
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user