feat: Implementar conexión de convocatorias vigentes con API de admisión y mejoras en el modal de detalles
parent
8f35717dc9
commit
2bdfb94859
@ -0,0 +1,156 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Administracion;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Comunicado;
|
||||
use App\Models\ComunicadoImagen;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Carbon\Carbon;
|
||||
|
||||
class ComunicadoController extends Controller
|
||||
{
|
||||
// Admin: lista paginada
|
||||
public function index(Request $request)
|
||||
{
|
||||
$comunicados = Comunicado::with('imagenes')
|
||||
->orderByDesc('created_at')
|
||||
->paginate(10);
|
||||
|
||||
return response()->json($comunicados);
|
||||
}
|
||||
|
||||
// Admin: crear comunicado
|
||||
public function store(Request $request)
|
||||
{
|
||||
$request->validate([
|
||||
'titulo' => 'required|string|max:255',
|
||||
'fecha_inicio' => 'nullable|date',
|
||||
'fecha_fin' => 'nullable|date|after_or_equal:fecha_inicio',
|
||||
'url_accion' => 'nullable|url|max:500',
|
||||
'texto_boton' => 'nullable|string|max:60',
|
||||
'imagenes' => 'required|array|min:1',
|
||||
'imagenes.*' => 'required|file|mimes:jpg,jpeg,png,webp|max:5120',
|
||||
]);
|
||||
|
||||
$comunicado = Comunicado::create([
|
||||
'titulo' => $request->titulo,
|
||||
'activo' => false,
|
||||
'fecha_inicio' => $request->fecha_inicio,
|
||||
'fecha_fin' => $request->fecha_fin,
|
||||
'url_accion' => $request->url_accion,
|
||||
'texto_boton' => $request->texto_boton,
|
||||
]);
|
||||
|
||||
foreach ($request->file('imagenes') as $orden => $imagen) {
|
||||
$filename = uniqid() . '.' . $imagen->getClientOriginalExtension();
|
||||
$path = "comunicados/{$comunicado->id}/{$filename}";
|
||||
Storage::disk('public')->put($path, file_get_contents($imagen->getRealPath()));
|
||||
|
||||
ComunicadoImagen::create([
|
||||
'comunicado_id' => $comunicado->id,
|
||||
'imagen_path' => $path,
|
||||
'orden' => $orden + 1,
|
||||
]);
|
||||
}
|
||||
|
||||
return response()->json($comunicado->load('imagenes'), 201);
|
||||
}
|
||||
|
||||
// Admin: actualizar comunicado
|
||||
public function update(Request $request, int $id)
|
||||
{
|
||||
$comunicado = Comunicado::findOrFail($id);
|
||||
|
||||
$request->validate([
|
||||
'titulo' => 'sometimes|required|string|max:255',
|
||||
'fecha_inicio' => 'nullable|date',
|
||||
'fecha_fin' => 'nullable|date|after_or_equal:fecha_inicio',
|
||||
'url_accion' => 'nullable|url|max:500',
|
||||
'texto_boton' => 'nullable|string|max:60',
|
||||
'imagenes' => 'sometimes|array|min:1',
|
||||
'imagenes.*' => 'file|mimes:jpg,jpeg,png,webp|max:5120',
|
||||
]);
|
||||
|
||||
$comunicado->update($request->only('titulo', 'fecha_inicio', 'fecha_fin', 'url_accion', 'texto_boton'));
|
||||
|
||||
// Si se enviaron nuevas imágenes, AGREGAR a las existentes (no reemplazar)
|
||||
if ($request->hasFile('imagenes')) {
|
||||
$maxOrden = $comunicado->imagenes()->max('orden') ?? 0;
|
||||
|
||||
foreach ($request->file('imagenes') as $idx => $imagen) {
|
||||
$filename = uniqid() . '.' . $imagen->getClientOriginalExtension();
|
||||
$path = "comunicados/{$comunicado->id}/{$filename}";
|
||||
Storage::disk('public')->put($path, file_get_contents($imagen->getRealPath()));
|
||||
|
||||
ComunicadoImagen::create([
|
||||
'comunicado_id' => $comunicado->id,
|
||||
'imagen_path' => $path,
|
||||
'orden' => $maxOrden + $idx + 1,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
return response()->json($comunicado->load('imagenes'));
|
||||
}
|
||||
|
||||
// Admin: eliminar comunicado
|
||||
public function destroy(int $id)
|
||||
{
|
||||
$comunicado = Comunicado::with('imagenes')->findOrFail($id);
|
||||
|
||||
foreach ($comunicado->imagenes as $img) {
|
||||
Storage::disk('public')->delete($img->imagen_path);
|
||||
}
|
||||
|
||||
$comunicado->delete();
|
||||
|
||||
return response()->json(['message' => 'Comunicado eliminado correctamente.']);
|
||||
}
|
||||
|
||||
// Admin: activar/desactivar (uno activo a la vez)
|
||||
public function toggleActivo(int $id)
|
||||
{
|
||||
$comunicado = Comunicado::findOrFail($id);
|
||||
|
||||
if ($comunicado->activo) {
|
||||
// Si ya estaba activo, simplemente lo desactiva
|
||||
$comunicado->update(['activo' => false]);
|
||||
} else {
|
||||
// Desactiva todos los demás y activa este
|
||||
Comunicado::where('activo', true)->update(['activo' => false]);
|
||||
$comunicado->update(['activo' => true]);
|
||||
}
|
||||
|
||||
return response()->json($comunicado->load('imagenes'));
|
||||
}
|
||||
|
||||
// Admin: eliminar una imagen individual
|
||||
public function destroyImagen(int $imagenId)
|
||||
{
|
||||
$imagen = ComunicadoImagen::findOrFail($imagenId);
|
||||
Storage::disk('public')->delete($imagen->imagen_path);
|
||||
$imagen->delete();
|
||||
|
||||
return response()->json(['message' => 'Imagen eliminada correctamente.']);
|
||||
}
|
||||
|
||||
// Público: devuelve el comunicado activo con sus imágenes (respeta vigencia)
|
||||
public function activo()
|
||||
{
|
||||
$hoy = Carbon::today();
|
||||
|
||||
$comunicado = Comunicado::with('imagenes')
|
||||
->where('activo', true)
|
||||
->where(function ($q) use ($hoy) {
|
||||
$q->whereNull('fecha_inicio')->orWhere('fecha_inicio', '<=', $hoy);
|
||||
})
|
||||
->where(function ($q) use ($hoy) {
|
||||
$q->whereNull('fecha_fin')->orWhere('fecha_fin', '>=', $hoy);
|
||||
})
|
||||
->first();
|
||||
|
||||
return response()->json($comunicado);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class Comunicado extends Model
|
||||
{
|
||||
protected $fillable = [
|
||||
'titulo',
|
||||
'activo',
|
||||
'fecha_inicio',
|
||||
'fecha_fin',
|
||||
'url_accion',
|
||||
'texto_boton',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'activo' => 'boolean',
|
||||
'fecha_inicio' => 'date',
|
||||
'fecha_fin' => 'date',
|
||||
];
|
||||
|
||||
public function imagenes(): \Illuminate\Database\Eloquent\Relations\HasMany
|
||||
{
|
||||
return $this->hasMany(ComunicadoImagen::class)->orderBy('orden');
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,35 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
class ComunicadoImagen extends Model
|
||||
{
|
||||
protected $table = 'comunicado_imagenes';
|
||||
|
||||
protected $fillable = [
|
||||
'comunicado_id',
|
||||
'imagen_path',
|
||||
'orden',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'orden' => 'integer',
|
||||
];
|
||||
|
||||
protected $appends = ['imagen_url'];
|
||||
|
||||
public function getImagenUrlAttribute(): ?string
|
||||
{
|
||||
return $this->imagen_path
|
||||
? Storage::disk('public')->url($this->imagen_path)
|
||||
: null;
|
||||
}
|
||||
|
||||
public function comunicado(): \Illuminate\Database\Eloquent\Relations\BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Comunicado::class);
|
||||
}
|
||||
}
|
||||
@ -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('comunicados', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->string('titulo');
|
||||
$table->boolean('activo')->default(false);
|
||||
$table->date('fecha_inicio')->nullable();
|
||||
$table->date('fecha_fin')->nullable();
|
||||
$table->timestamps();
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('comunicados');
|
||||
}
|
||||
};
|
||||
@ -0,0 +1,24 @@
|
||||
<?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('comunicado_imagenes', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('comunicado_id')->constrained('comunicados')->cascadeOnDelete();
|
||||
$table->string('imagen_path');
|
||||
$table->integer('orden')->default(1);
|
||||
$table->timestamps();
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('comunicado_imagenes');
|
||||
}
|
||||
};
|
||||
@ -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('comunicados', function (Blueprint $table) {
|
||||
$table->string('url_accion')->nullable()->after('fecha_fin');
|
||||
$table->string('texto_boton')->nullable()->after('url_accion');
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('comunicados', function (Blueprint $table) {
|
||||
$table->dropColumn(['url_accion', 'texto_boton']);
|
||||
});
|
||||
}
|
||||
};
|
||||
@ -0,0 +1,368 @@
|
||||
<template>
|
||||
<!-- v-if garantiza que si no hay comunicado activo el modal ni se monta -->
|
||||
<a-modal
|
||||
v-if="imagenes.length > 0"
|
||||
v-model:open="visible"
|
||||
:footer="null"
|
||||
:closable="false"
|
||||
:mask-closable="true"
|
||||
:keyboard="true"
|
||||
:destroy-on-close="true"
|
||||
:width="'auto'"
|
||||
centered
|
||||
wrap-class-name="comunicado-modal-wrap"
|
||||
transition-name="comunicado-slide"
|
||||
@cancel="cerrar"
|
||||
>
|
||||
<div class="comunicado-modal">
|
||||
<!-- Botón cerrar -->
|
||||
<button class="btn-cerrar" @click="cerrar" aria-label="Cerrar">
|
||||
<CloseOutlined />
|
||||
</button>
|
||||
|
||||
<!-- Una sola imagen -->
|
||||
<template v-if="imagenes.length === 1">
|
||||
<img
|
||||
:src="imagenes[0].imagen_url"
|
||||
:alt="comunicado.titulo"
|
||||
class="comunicado-img"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<!-- Carrusel si hay más de una imagen -->
|
||||
<template v-else-if="imagenes.length > 1">
|
||||
<div class="custom-carousel">
|
||||
<transition name="fade">
|
||||
<div :key="currentIndex" class="img-wrapper">
|
||||
<img
|
||||
:src="imagenes[currentIndex].imagen_url"
|
||||
:alt="comunicado.titulo"
|
||||
class="comunicado-img"
|
||||
/>
|
||||
<button class="arrow arrow-prev" @click="prev" aria-label="Anterior">
|
||||
<LeftOutlined />
|
||||
</button>
|
||||
<button class="arrow arrow-next" @click="next" aria-label="Siguiente">
|
||||
<RightOutlined />
|
||||
</button>
|
||||
</div>
|
||||
</transition>
|
||||
|
||||
<div class="dots">
|
||||
<span
|
||||
v-for="(_, i) in imagenes"
|
||||
:key="i"
|
||||
:class="['dot', { active: i === currentIndex }]"
|
||||
@click="currentIndex = i"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<!-- Barra inferior: siempre visible -->
|
||||
<div class="accion-bar">
|
||||
<a
|
||||
v-if="comunicado?.url_accion"
|
||||
:href="comunicado.url_accion"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="btn-accion"
|
||||
>
|
||||
{{ comunicado.texto_boton || 'Ver más' }}
|
||||
</a>
|
||||
<button class="btn-cerrar-bar" @click="cerrar">Cerrar</button>
|
||||
</div>
|
||||
</div>
|
||||
</a-modal>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, watch, onMounted } from 'vue'
|
||||
import { CloseOutlined, LeftOutlined, RightOutlined } from '@ant-design/icons-vue'
|
||||
import { useComunicadosStore } from '../../store/comunicadosStore'
|
||||
|
||||
const store = useComunicadosStore()
|
||||
const visible = ref(false)
|
||||
const currentIndex = ref(0)
|
||||
|
||||
const comunicado = computed(() => store.comunicadoActivo)
|
||||
const imagenes = computed(() => store.comunicadoActivo?.imagenes ?? [])
|
||||
|
||||
function prev() {
|
||||
currentIndex.value = (currentIndex.value - 1 + imagenes.value.length) % imagenes.value.length
|
||||
}
|
||||
|
||||
function next() {
|
||||
currentIndex.value = (currentIndex.value + 1) % imagenes.value.length
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await store.fetchActivo()
|
||||
if (store.comunicadoActivo && imagenes.value.length > 0) {
|
||||
visible.value = true
|
||||
}
|
||||
})
|
||||
|
||||
// Si el comunicado activo desaparece (ej. desactivado desde admin), cerrar el modal
|
||||
watch(
|
||||
() => store.comunicadoActivo,
|
||||
(val) => {
|
||||
if (!val) visible.value = false
|
||||
}
|
||||
)
|
||||
|
||||
function cerrar() {
|
||||
visible.value = false
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
/* A. Animación de apertura: slide-up + fade */
|
||||
.comunicado-slide-enter-active {
|
||||
animation: comunicadoSlideUp 0.38s cubic-bezier(0.34, 1.1, 0.64, 1);
|
||||
}
|
||||
.comunicado-slide-leave-active {
|
||||
animation: comunicadoSlideDown 0.22s ease forwards;
|
||||
}
|
||||
|
||||
@keyframes comunicadoSlideUp {
|
||||
from { transform: translateY(40px) scale(0.97); opacity: 0; }
|
||||
to { transform: translateY(0) scale(1); opacity: 1; }
|
||||
}
|
||||
|
||||
@keyframes comunicadoSlideDown {
|
||||
from { transform: translateY(0); opacity: 1; }
|
||||
to { transform: translateY(24px); opacity: 0; }
|
||||
}
|
||||
|
||||
.comunicado-modal-wrap .ant-modal {
|
||||
width: auto !important;
|
||||
max-width: 94vw;
|
||||
}
|
||||
|
||||
.comunicado-modal-wrap .ant-modal-content {
|
||||
padding: 0;
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
background: transparent;
|
||||
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
|
||||
.comunicado-modal-wrap .ant-modal-body {
|
||||
padding: 0;
|
||||
}
|
||||
</style>
|
||||
|
||||
<style scoped>
|
||||
.comunicado-modal {
|
||||
position: relative;
|
||||
line-height: 0;
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.comunicado-img {
|
||||
display: block;
|
||||
width: auto;
|
||||
max-width: 100%;
|
||||
max-height: 75vh;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.btn-cerrar {
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
right: 10px;
|
||||
z-index: 20;
|
||||
background: rgba(0, 0, 0, 0.55);
|
||||
border: none;
|
||||
color: white;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 50%;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 14px;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.btn-cerrar:hover {
|
||||
background: rgba(0, 0, 0, 0.8);
|
||||
}
|
||||
|
||||
/* Carrusel propio */
|
||||
.custom-carousel {
|
||||
position: relative;
|
||||
line-height: 0;
|
||||
}
|
||||
|
||||
/* Wrapper que envuelve imagen + flechas; se ajusta al ancho natural de la imagen */
|
||||
.img-wrapper {
|
||||
position: relative;
|
||||
width: fit-content;
|
||||
max-width: 100%;
|
||||
margin: 0 auto;
|
||||
line-height: 0;
|
||||
}
|
||||
|
||||
/* B. Transición entre imágenes: crossfade suave */
|
||||
.fade-enter-active {
|
||||
transition: opacity 0.7s ease-in-out;
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.fade-leave-active {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
transition: opacity 0.7s ease-in-out;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.fade-enter-from { opacity: 0; }
|
||||
.fade-enter-to { opacity: 1; }
|
||||
.fade-leave-from { opacity: 1; }
|
||||
.fade-leave-to { opacity: 0; }
|
||||
|
||||
/* Flechas */
|
||||
.arrow {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
z-index: 10;
|
||||
background: rgba(0, 0, 0, 0.45);
|
||||
border: none;
|
||||
color: white;
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
border-radius: 50%;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 14px;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.arrow:hover {
|
||||
background: rgba(0, 0, 0, 0.75);
|
||||
}
|
||||
|
||||
.arrow-prev { left: 10px; }
|
||||
.arrow-next { right: 10px; }
|
||||
|
||||
/* Dots */
|
||||
.dots {
|
||||
position: absolute;
|
||||
bottom: 10px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
/* C. Dots tipo stories */
|
||||
.dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 4px;
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
border: 1px solid rgba(255, 255, 255, 0.5);
|
||||
cursor: pointer;
|
||||
transition: width 0.3s ease, background 0.2s, border-color 0.2s;
|
||||
}
|
||||
|
||||
.dot.active {
|
||||
width: 24px;
|
||||
background: rgba(255, 255, 255, 0.55);
|
||||
border-color: rgba(255, 255, 255, 0.7);
|
||||
}
|
||||
|
||||
/* Barra inferior */
|
||||
.accion-bar {
|
||||
line-height: normal;
|
||||
background: #fff;
|
||||
padding: 12px 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.btn-accion {
|
||||
display: inline-block;
|
||||
background: #1890ff;
|
||||
color: white;
|
||||
padding: 8px 28px;
|
||||
border-radius: 6px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
text-decoration: none;
|
||||
transition: background 0.2s;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.btn-accion:hover {
|
||||
background: #096dd9;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-cerrar-bar {
|
||||
background: transparent;
|
||||
border: 1px solid #d9d9d9;
|
||||
color: #555;
|
||||
padding: 8px 24px;
|
||||
border-radius: 6px;
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.btn-cerrar-bar:hover {
|
||||
border-color: #1890ff;
|
||||
color: #1890ff;
|
||||
}
|
||||
|
||||
/* ── Responsive mobile ── */
|
||||
@media (max-width: 576px) {
|
||||
/* Imagen más corta en mobile para dejar espacio a la barra inferior */
|
||||
.comunicado-img {
|
||||
max-height: 58vh;
|
||||
}
|
||||
|
||||
/* Touch targets más grandes */
|
||||
.btn-cerrar {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
font-size: 16px;
|
||||
top: 8px;
|
||||
right: 8px;
|
||||
}
|
||||
|
||||
.arrow {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
/* Barra inferior: stack vertical si hay dos botones */
|
||||
.accion-bar {
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.btn-accion,
|
||||
.btn-cerrar-bar {
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
padding: 10px 16px;
|
||||
font-size: 15px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@ -0,0 +1,128 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import api from '../axios'
|
||||
import apiPublico from '../axiosPostulante'
|
||||
|
||||
export const useComunicadosStore = defineStore('comunicados', {
|
||||
state: () => ({
|
||||
comunicados: [],
|
||||
comunicadoActivo: null,
|
||||
pagination: { current_page: 1, per_page: 10, total: 0 },
|
||||
loading: false,
|
||||
error: null,
|
||||
}),
|
||||
|
||||
actions: {
|
||||
_setError(err) {
|
||||
this.error = err?.response?.data?.message || 'Ocurrió un error'
|
||||
},
|
||||
|
||||
// Admin: lista paginada
|
||||
async fetchComunicados(params = {}) {
|
||||
this.loading = true
|
||||
this.error = null
|
||||
try {
|
||||
const { data } = await api.get('/admin/comunicados', { params })
|
||||
this.comunicados = data.data ?? []
|
||||
this.pagination = {
|
||||
current_page: data.current_page,
|
||||
per_page: data.per_page,
|
||||
total: data.total,
|
||||
}
|
||||
return true
|
||||
} catch (err) {
|
||||
this._setError(err)
|
||||
return false
|
||||
} finally {
|
||||
this.loading = false
|
||||
}
|
||||
},
|
||||
|
||||
// Admin: crear
|
||||
async createComunicado(formData) {
|
||||
this.error = null
|
||||
try {
|
||||
const { data } = await api.post('/admin/comunicados', formData, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' },
|
||||
})
|
||||
this.comunicados.unshift(data)
|
||||
return true
|
||||
} catch (err) {
|
||||
this._setError(err)
|
||||
return false
|
||||
}
|
||||
},
|
||||
|
||||
// Admin: actualizar
|
||||
async updateComunicado(id, formData) {
|
||||
this.error = null
|
||||
try {
|
||||
const { data } = await api.post(`/admin/comunicados/${id}`, formData, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' },
|
||||
})
|
||||
const idx = this.comunicados.findIndex((c) => c.id === id)
|
||||
if (idx !== -1) this.comunicados[idx] = data
|
||||
return true
|
||||
} catch (err) {
|
||||
this._setError(err)
|
||||
return false
|
||||
}
|
||||
},
|
||||
|
||||
// Admin: eliminar
|
||||
async deleteComunicado(id) {
|
||||
this.error = null
|
||||
try {
|
||||
await api.delete(`/admin/comunicados/${id}`)
|
||||
this.comunicados = this.comunicados.filter((c) => c.id !== id)
|
||||
return true
|
||||
} catch (err) {
|
||||
this._setError(err)
|
||||
return false
|
||||
}
|
||||
},
|
||||
|
||||
// Admin: toggle activo
|
||||
async toggleActivo(id) {
|
||||
this.error = null
|
||||
try {
|
||||
const { data } = await api.patch(`/admin/comunicados/${id}/toggle-activo`)
|
||||
// Actualiza el estado local: desactiva todos y activa el correspondiente
|
||||
this.comunicados = this.comunicados.map((c) => ({
|
||||
...c,
|
||||
activo: c.id === id ? data.activo : false,
|
||||
}))
|
||||
return true
|
||||
} catch (err) {
|
||||
this._setError(err)
|
||||
return false
|
||||
}
|
||||
},
|
||||
|
||||
// Admin: eliminar imagen individual
|
||||
async deleteImagen(comunicadoId, imagenId) {
|
||||
this.error = null
|
||||
try {
|
||||
await api.delete(`/admin/comunicados/imagenes/${imagenId}`)
|
||||
const comunicado = this.comunicados.find((c) => c.id === comunicadoId)
|
||||
if (comunicado) {
|
||||
comunicado.imagenes = comunicado.imagenes.filter((img) => img.id !== imagenId)
|
||||
}
|
||||
return true
|
||||
} catch (err) {
|
||||
this._setError(err)
|
||||
return false
|
||||
}
|
||||
},
|
||||
|
||||
// Público: comunicado activo
|
||||
async fetchActivo() {
|
||||
this.error = null
|
||||
try {
|
||||
const { data } = await apiPublico.get('/comunicados/activo')
|
||||
this.comunicadoActivo = data
|
||||
} catch (err) {
|
||||
this.comunicadoActivo = null
|
||||
}
|
||||
},
|
||||
},
|
||||
})
|
||||
@ -0,0 +1,493 @@
|
||||
<template>
|
||||
<div class="areas-container">
|
||||
|
||||
<!-- Header -->
|
||||
<div class="areas-header">
|
||||
<div class="header-title">
|
||||
<h2>Comunicados</h2>
|
||||
<p class="subtitle">Gestiona los comunicados que aparecen en la web pública</p>
|
||||
</div>
|
||||
<a-button type="primary" @click="abrirModalCrear">
|
||||
<template #icon><PlusOutlined /></template>
|
||||
Nuevo comunicado
|
||||
</a-button>
|
||||
</div>
|
||||
|
||||
<!-- Tabla -->
|
||||
<div class="areas-table-container">
|
||||
<a-table
|
||||
:columns="columnas"
|
||||
:data-source="store.comunicados"
|
||||
:loading="store.loading"
|
||||
:pagination="{
|
||||
current: store.pagination.current_page,
|
||||
pageSize: store.pagination.per_page,
|
||||
total: store.pagination.total,
|
||||
showTotal: (t) => `${t} comunicados`,
|
||||
}"
|
||||
row-key="id"
|
||||
class="areas-table"
|
||||
@change="onTableChange"
|
||||
>
|
||||
<template #bodyCell="{ column, record }">
|
||||
|
||||
<!-- Preview imágenes -->
|
||||
<template v-if="column.key === 'imagenes'">
|
||||
<div class="thumb-group">
|
||||
<a-image
|
||||
v-for="img in record.imagenes"
|
||||
:key="img.id"
|
||||
:src="img.imagen_url"
|
||||
:width="48"
|
||||
:height="40"
|
||||
style="object-fit: cover; border-radius: 6px; border: 1px solid #f0f0f0;"
|
||||
/>
|
||||
<div v-if="!record.imagenes?.length" class="thumb-empty">Sin imagen</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Estado activo -->
|
||||
<template v-else-if="column.key === 'activo'">
|
||||
<a-switch
|
||||
:checked="record.activo"
|
||||
:loading="togglingId === record.id"
|
||||
checked-children="Activo"
|
||||
un-checked-children="Inactivo"
|
||||
@change="onToggleActivo(record)"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<!-- Vigencia -->
|
||||
<template v-else-if="column.key === 'vigencia'">
|
||||
<span v-if="record.fecha_inicio || record.fecha_fin" style="font-size: 12px; color: #666;">
|
||||
{{ record.fecha_inicio ?? '∞' }} → {{ record.fecha_fin ?? '∞' }}
|
||||
</span>
|
||||
<a-tag v-else color="default">Sin vigencia</a-tag>
|
||||
</template>
|
||||
|
||||
<!-- Botón acción -->
|
||||
<template v-else-if="column.key === 'url_accion'">
|
||||
<a
|
||||
v-if="record.url_accion"
|
||||
:href="record.url_accion"
|
||||
target="_blank"
|
||||
style="font-size: 12px;"
|
||||
>
|
||||
{{ record.texto_boton || 'Ver más' }}
|
||||
</a>
|
||||
<span v-else style="color: #bbb; font-size: 12px;">—</span>
|
||||
</template>
|
||||
|
||||
<!-- Acciones -->
|
||||
<template v-else-if="column.key === 'acciones'">
|
||||
<a-space>
|
||||
<a-button type="link" class="action-btn" @click="abrirModalEditar(record)">
|
||||
<EditOutlined /> Editar
|
||||
</a-button>
|
||||
<a-button type="link" danger class="action-btn" @click="confirmarEliminar(record)">
|
||||
<DeleteOutlined /> Eliminar
|
||||
</a-button>
|
||||
</a-space>
|
||||
</template>
|
||||
|
||||
</template>
|
||||
</a-table>
|
||||
</div>
|
||||
|
||||
<!-- Modal crear / editar -->
|
||||
<a-modal
|
||||
v-model:open="modalVisible"
|
||||
:title="modoEdicion ? 'Editar comunicado' : 'Nuevo comunicado'"
|
||||
:confirm-loading="guardando"
|
||||
ok-text="Guardar"
|
||||
cancel-text="Cancelar"
|
||||
width="560px"
|
||||
class="area-modal"
|
||||
@ok="onGuardar"
|
||||
@cancel="cerrarModal"
|
||||
>
|
||||
<a-form :model="form" layout="vertical" ref="formRef">
|
||||
|
||||
<a-form-item
|
||||
label="Título (referencia interna)"
|
||||
name="titulo"
|
||||
:rules="[{ required: true, message: 'El título es requerido' }]"
|
||||
>
|
||||
<a-input v-model:value="form.titulo" placeholder="Ej: Comunicado cronograma Feb 2026" />
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item label="Vigencia (opcional)">
|
||||
<a-space>
|
||||
<a-date-picker
|
||||
v-model:value="form.fecha_inicio"
|
||||
placeholder="Desde"
|
||||
format="YYYY-MM-DD"
|
||||
value-format="YYYY-MM-DD"
|
||||
/>
|
||||
<span style="color: #999;">→</span>
|
||||
<a-date-picker
|
||||
v-model:value="form.fecha_fin"
|
||||
placeholder="Hasta"
|
||||
format="YYYY-MM-DD"
|
||||
value-format="YYYY-MM-DD"
|
||||
/>
|
||||
</a-space>
|
||||
<div style="font-size: 12px; color: #999; margin-top: 4px;">
|
||||
Si no se define, el comunicado no tiene fecha límite.
|
||||
</div>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item label="Botón de acción (opcional)">
|
||||
<a-space direction="vertical" style="width: 100%;">
|
||||
<a-input
|
||||
v-model:value="form.url_accion"
|
||||
placeholder="https://ejemplo.com/inscripcion"
|
||||
addon-before="URL"
|
||||
/>
|
||||
<a-input
|
||||
v-model:value="form.texto_boton"
|
||||
placeholder="Ej: Inscríbete aquí, Ver cronograma..."
|
||||
addon-before="Texto"
|
||||
:maxlength="60"
|
||||
show-count
|
||||
/>
|
||||
</a-space>
|
||||
<div style="font-size: 12px; color: #999; margin-top: 4px;">
|
||||
Si defines una URL, aparecerá un botón en el modal de la web.
|
||||
</div>
|
||||
</a-form-item>
|
||||
|
||||
<!-- Imágenes actuales (solo en edición) -->
|
||||
<a-form-item v-if="modoEdicion && imagenesActuales.length > 0" label="Imágenes actuales">
|
||||
<div style="display: flex; gap: 8px; flex-wrap: wrap;">
|
||||
<div
|
||||
v-for="img in imagenesActuales"
|
||||
:key="img.id"
|
||||
style="position: relative; display: inline-block;"
|
||||
>
|
||||
<a-image
|
||||
:src="img.imagen_url"
|
||||
:width="72"
|
||||
:height="72"
|
||||
style="object-fit: cover; border-radius: 4px; border: 1px solid #f0f0f0;"
|
||||
/>
|
||||
<a-popconfirm
|
||||
title="¿Eliminar esta imagen?"
|
||||
ok-text="Sí"
|
||||
cancel-text="No"
|
||||
@confirm="onEliminarImagen(img)"
|
||||
>
|
||||
<button class="btn-remove-img">✕</button>
|
||||
</a-popconfirm>
|
||||
</div>
|
||||
</div>
|
||||
<div style="font-size: 12px; color: #999; margin-top: 4px;">
|
||||
Haz clic en ✕ para eliminar una imagen existente.
|
||||
</div>
|
||||
</a-form-item>
|
||||
|
||||
<!-- Agregar imágenes -->
|
||||
<a-form-item
|
||||
:label="modoEdicion ? 'Agregar más imágenes' : 'Imágenes del comunicado'"
|
||||
name="imagenes"
|
||||
:rules="modoEdicion ? [] : [{ validator: validarImagenes }]"
|
||||
>
|
||||
<a-upload
|
||||
v-model:file-list="fileImagenes"
|
||||
list-type="picture-card"
|
||||
:before-upload="() => false"
|
||||
accept="image/jpeg,image/png,image/webp"
|
||||
multiple
|
||||
>
|
||||
<div v-if="fileImagenes.length < 8">
|
||||
<PlusOutlined />
|
||||
<div style="margin-top: 8px; font-size: 12px;">Agregar</div>
|
||||
</div>
|
||||
</a-upload>
|
||||
<div v-if="modoEdicion" style="font-size: 12px; color: #999; margin-top: 4px;">
|
||||
Las imágenes que subas se agregarán a las existentes.
|
||||
</div>
|
||||
</a-form-item>
|
||||
|
||||
<a-alert v-if="store.error" type="error" show-icon :message="store.error" />
|
||||
|
||||
</a-form>
|
||||
</a-modal>
|
||||
|
||||
<!-- Modal eliminar -->
|
||||
<a-modal
|
||||
v-model:open="deleteModalVisible"
|
||||
title="Eliminar comunicado"
|
||||
ok-type="danger"
|
||||
ok-text="Eliminar"
|
||||
cancel-text="Cancelar"
|
||||
:confirm-loading="eliminando"
|
||||
width="480px"
|
||||
@ok="onEliminar"
|
||||
@cancel="deleteModalVisible = false"
|
||||
>
|
||||
<a-alert type="warning" show-icon message="¿Deseas eliminar este comunicado?" />
|
||||
<div class="delete-info">
|
||||
<p><strong>{{ comunicadoAEliminar?.titulo }}</strong></p>
|
||||
<p class="muted">Esta acción eliminará también todas sus imágenes y no se puede deshacer.</p>
|
||||
</div>
|
||||
</a-modal>
|
||||
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { message } from 'ant-design-vue'
|
||||
import { PlusOutlined, EditOutlined, DeleteOutlined } from '@ant-design/icons-vue'
|
||||
import { useComunicadosStore } from '../../../store/comunicadosStore'
|
||||
|
||||
const store = useComunicadosStore()
|
||||
|
||||
const columnas = [
|
||||
{ title: 'Título', dataIndex: 'titulo', key: 'titulo' },
|
||||
{ title: 'Imágenes', key: 'imagenes', width: 160 },
|
||||
{ title: 'Activo', key: 'activo', width: 140 },
|
||||
{ title: 'Vigencia', key: 'vigencia', width: 200 },
|
||||
{ title: 'Botón web', key: 'url_accion', width: 130 },
|
||||
{ title: 'Acciones', key: 'acciones', width: 160, align: 'center' },
|
||||
]
|
||||
|
||||
// Estado modales
|
||||
const modalVisible = ref(false)
|
||||
const deleteModalVisible = ref(false)
|
||||
const modoEdicion = ref(false)
|
||||
const comunicadoEditando = ref(null)
|
||||
const comunicadoAEliminar = ref(null)
|
||||
const guardando = ref(false)
|
||||
const eliminando = ref(false)
|
||||
const togglingId = ref(null)
|
||||
const formRef = ref(null)
|
||||
|
||||
// Form
|
||||
const form = ref({ titulo: '', fecha_inicio: null, fecha_fin: null, url_accion: '', texto_boton: '' })
|
||||
const fileImagenes = ref([])
|
||||
|
||||
const imagenesActuales = computed(() => comunicadoEditando.value?.imagenes ?? [])
|
||||
|
||||
onMounted(() => {
|
||||
store.fetchComunicados()
|
||||
})
|
||||
|
||||
function onTableChange(pagination) {
|
||||
store.fetchComunicados({ page: pagination.current })
|
||||
}
|
||||
|
||||
function abrirModalCrear() {
|
||||
modoEdicion.value = false
|
||||
comunicadoEditando.value = null
|
||||
form.value = { titulo: '', fecha_inicio: null, fecha_fin: null, url_accion: '', texto_boton: '' }
|
||||
fileImagenes.value = []
|
||||
modalVisible.value = true
|
||||
}
|
||||
|
||||
function abrirModalEditar(record) {
|
||||
modoEdicion.value = true
|
||||
comunicadoEditando.value = record
|
||||
form.value = {
|
||||
titulo: record.titulo,
|
||||
fecha_inicio: record.fecha_inicio ?? null,
|
||||
fecha_fin: record.fecha_fin ?? null,
|
||||
url_accion: record.url_accion ?? '',
|
||||
texto_boton: record.texto_boton ?? '',
|
||||
}
|
||||
fileImagenes.value = []
|
||||
modalVisible.value = true
|
||||
}
|
||||
|
||||
function cerrarModal() {
|
||||
modalVisible.value = false
|
||||
formRef.value?.resetFields()
|
||||
}
|
||||
|
||||
async function validarImagenes() {
|
||||
if (!modoEdicion.value && fileImagenes.value.length === 0) {
|
||||
return Promise.reject('Agrega al menos una imagen')
|
||||
}
|
||||
return Promise.resolve()
|
||||
}
|
||||
|
||||
async function onGuardar() {
|
||||
try {
|
||||
await formRef.value.validate()
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
|
||||
const fd = new FormData()
|
||||
fd.append('titulo', form.value.titulo)
|
||||
if (form.value.fecha_inicio) fd.append('fecha_inicio', form.value.fecha_inicio)
|
||||
if (form.value.fecha_fin) fd.append('fecha_fin', form.value.fecha_fin)
|
||||
if (form.value.url_accion) fd.append('url_accion', form.value.url_accion)
|
||||
if (form.value.texto_boton) fd.append('texto_boton', form.value.texto_boton)
|
||||
fileImagenes.value.forEach((f) => fd.append('imagenes[]', f.originFileObj))
|
||||
|
||||
guardando.value = true
|
||||
const ok = modoEdicion.value
|
||||
? await store.updateComunicado(comunicadoEditando.value.id, fd)
|
||||
: await store.createComunicado(fd)
|
||||
guardando.value = false
|
||||
|
||||
if (ok) {
|
||||
message.success(modoEdicion.value ? 'Comunicado actualizado' : 'Comunicado creado')
|
||||
cerrarModal()
|
||||
store.fetchComunicados()
|
||||
} else {
|
||||
message.error(store.error ?? 'Error al guardar')
|
||||
}
|
||||
}
|
||||
|
||||
function confirmarEliminar(record) {
|
||||
comunicadoAEliminar.value = record
|
||||
deleteModalVisible.value = true
|
||||
}
|
||||
|
||||
async function onEliminar() {
|
||||
eliminando.value = true
|
||||
const ok = await store.deleteComunicado(comunicadoAEliminar.value.id)
|
||||
eliminando.value = false
|
||||
if (ok) {
|
||||
message.success('Comunicado eliminado')
|
||||
deleteModalVisible.value = false
|
||||
} else {
|
||||
message.error(store.error ?? 'Error al eliminar')
|
||||
}
|
||||
}
|
||||
|
||||
async function onEliminarImagen(img) {
|
||||
const ok = await store.deleteImagen(comunicadoEditando.value.id, img.id)
|
||||
if (ok) {
|
||||
message.success('Imagen eliminada')
|
||||
} else {
|
||||
message.error(store.error ?? 'Error al eliminar imagen')
|
||||
}
|
||||
}
|
||||
|
||||
async function onToggleActivo(record) {
|
||||
togglingId.value = record.id
|
||||
const ok = await store.toggleActivo(record.id)
|
||||
togglingId.value = null
|
||||
if (!ok) message.error(store.error ?? 'Error al cambiar estado')
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.areas-container {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.areas-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 24px;
|
||||
padding: 0 8px;
|
||||
}
|
||||
|
||||
.header-title h2 {
|
||||
margin: 0;
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
color: #1f1f1f;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
margin: 4px 0 0 0;
|
||||
color: #666;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.areas-table-container {
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
|
||||
border: 1px solid #f0f0f0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.areas-table :deep(.ant-table-thead > tr > th) {
|
||||
background: #fafafa;
|
||||
font-weight: 600;
|
||||
color: #1f1f1f;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
}
|
||||
|
||||
.areas-table :deep(.ant-table-tbody > tr > td) {
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
}
|
||||
|
||||
.areas-table :deep(.ant-table-tbody > tr:hover > td) {
|
||||
background: #fafafa;
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
padding: 4px 8px;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.thumb-group {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.thumb-empty {
|
||||
width: 48px;
|
||||
height: 40px;
|
||||
border-radius: 6px;
|
||||
border: 1px dashed #d9d9d9;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
font-size: 11px;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.btn-remove-img {
|
||||
position: absolute;
|
||||
top: -6px;
|
||||
right: -6px;
|
||||
background: #ff4d4f;
|
||||
border: none;
|
||||
color: white;
|
||||
border-radius: 50%;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
cursor: pointer;
|
||||
font-size: 10px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.delete-info {
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.muted {
|
||||
color: #777;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.areas-header {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.areas-table-container {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.areas-table {
|
||||
min-width: 900px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
Loading…
Reference in New Issue