Fix: socket permissions para nginx

This commit is contained in:
2026-05-21 23:21:41 -06:00
parent b574ae1ed3
commit 28faa727b1
32 changed files with 2144 additions and 6 deletions
@@ -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
+281
View File
@@ -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
+148
View File
@@ -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
+168
View File
@@ -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
+162
View File
@@ -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>&times;</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
+1
View File
@@ -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"
+1 -1
View File
@@ -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">