diff --git a/back/app/Http/Controllers/Administracion/ComunicadoController.php b/back/app/Http/Controllers/Administracion/ComunicadoController.php
new file mode 100644
index 0000000..1477124
--- /dev/null
+++ b/back/app/Http/Controllers/Administracion/ComunicadoController.php
@@ -0,0 +1,156 @@
+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);
+ }
+}
diff --git a/back/app/Models/Comunicado.php b/back/app/Models/Comunicado.php
new file mode 100644
index 0000000..e89a268
--- /dev/null
+++ b/back/app/Models/Comunicado.php
@@ -0,0 +1,28 @@
+ 'boolean',
+ 'fecha_inicio' => 'date',
+ 'fecha_fin' => 'date',
+ ];
+
+ public function imagenes(): \Illuminate\Database\Eloquent\Relations\HasMany
+ {
+ return $this->hasMany(ComunicadoImagen::class)->orderBy('orden');
+ }
+}
diff --git a/back/app/Models/ComunicadoImagen.php b/back/app/Models/ComunicadoImagen.php
new file mode 100644
index 0000000..b6aba7c
--- /dev/null
+++ b/back/app/Models/ComunicadoImagen.php
@@ -0,0 +1,35 @@
+ '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);
+ }
+}
diff --git a/back/database/migrations/2026_02_27_000001_create_comunicados_table.php b/back/database/migrations/2026_02_27_000001_create_comunicados_table.php
new file mode 100644
index 0000000..5dd180c
--- /dev/null
+++ b/back/database/migrations/2026_02_27_000001_create_comunicados_table.php
@@ -0,0 +1,25 @@
+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');
+ }
+};
diff --git a/back/database/migrations/2026_02_27_000002_create_comunicado_imagenes_table.php b/back/database/migrations/2026_02_27_000002_create_comunicado_imagenes_table.php
new file mode 100644
index 0000000..2c053b6
--- /dev/null
+++ b/back/database/migrations/2026_02_27_000002_create_comunicado_imagenes_table.php
@@ -0,0 +1,24 @@
+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');
+ }
+};
diff --git a/back/database/migrations/2026_02_28_012810_add_url_accion_to_comunicados_table.php b/back/database/migrations/2026_02_28_012810_add_url_accion_to_comunicados_table.php
new file mode 100644
index 0000000..c5d952e
--- /dev/null
+++ b/back/database/migrations/2026_02_28_012810_add_url_accion_to_comunicados_table.php
@@ -0,0 +1,23 @@
+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']);
+ });
+ }
+};
diff --git a/back/routes/api.php b/back/routes/api.php
index 0bbd438..de7c509 100644
--- a/back/routes/api.php
+++ b/back/routes/api.php
@@ -19,6 +19,7 @@ use App\Http\Controllers\Administracion\PostulanteController;
use App\Http\Controllers\Administracion\CalificacionController;
use App\Http\Controllers\Administracion\NoticiaController;
use App\Http\Controllers\Administracion\ProcesoAdmisionResultadoArchivoController;
+use App\Http\Controllers\Administracion\ComunicadoController;
use App\Http\Controllers\WebController;
Route::get('/user', function (Request $request) {
@@ -212,4 +213,17 @@ Route::middleware('auth:sanctum')->prefix('admin')->group(function () {
Route::middleware('auth:sanctum')->get(
'/mis-procesos/{idProceso}/avance',
[PostulanteAuthController::class, 'obtenerAvanceProcesoPostulante']
-);
\ No newline at end of file
+);
+
+// Público: comunicado activo
+Route::get('/comunicados/activo', [ComunicadoController::class, 'activo']);
+
+// Admin: CRUD comunicados
+Route::middleware('auth:sanctum')->prefix('admin')->group(function () {
+ Route::get('/comunicados', [ComunicadoController::class, 'index']);
+ Route::post('/comunicados', [ComunicadoController::class, 'store']);
+ Route::match(['post', 'put', 'patch'], '/comunicados/{id}', [ComunicadoController::class, 'update']);
+ Route::delete('/comunicados/{id}', [ComunicadoController::class, 'destroy']);
+ Route::patch('/comunicados/{id}/toggle-activo', [ComunicadoController::class, 'toggleActivo']);
+ Route::delete('/comunicados/imagenes/{imagenId}', [ComunicadoController::class, 'destroyImagen']);
+});
\ No newline at end of file
diff --git a/front/src/components/WebPage.vue b/front/src/components/WebPage.vue
index 4616dd9..3a21ec8 100644
--- a/front/src/components/WebPage.vue
+++ b/front/src/components/WebPage.vue
@@ -24,6 +24,8 @@
Examen: {{ formatFecha(store.procesoPrincipal.fecha_examen1) }} + - + {{ formatFecha(store.procesoPrincipal.fecha_examen2) }}
diff --git a/front/src/router/index.js b/front/src/router/index.js index 1960778..429f366 100644 --- a/front/src/router/index.js +++ b/front/src/router/index.js @@ -209,6 +209,11 @@ const routes = [ path: '/admin/dashboard/noticias', name: 'NoticiasAdmisionList', component: () => import('../views/administrador/procesoadmision/NoticiasAdmin.vue') + }, + { + path: '/admin/dashboard/comunicados', + name: 'ComunicadosAdmin', + component: () => import('../views/administrador/comunicados/ComunicadosAdmin.vue') } ] diff --git a/front/src/store/comunicadosStore.js b/front/src/store/comunicadosStore.js new file mode 100644 index 0000000..d534610 --- /dev/null +++ b/front/src/store/comunicadosStore.js @@ -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 + } + }, + }, +}) diff --git a/front/src/views/administrador/comunicados/ComunicadosAdmin.vue b/front/src/views/administrador/comunicados/ComunicadosAdmin.vue new file mode 100644 index 0000000..172636d --- /dev/null +++ b/front/src/views/administrador/comunicados/ComunicadosAdmin.vue @@ -0,0 +1,493 @@ + +Gestiona los comunicados que aparecen en la web pública
+{{ comunicadoAEliminar?.titulo }}
+Esta acción eliminará también todas sus imágenes y no se puede deshacer.
+