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