From 2bdfb948590f4a2c7fa26f1683d5f2ae0ce79f4c Mon Sep 17 00:00:00 2001 From: Anghelo Flores Date: Fri, 27 Feb 2026 22:27:26 -0500 Subject: [PATCH] =?UTF-8?q?feat:=20Implementar=20conexi=C3=B3n=20de=20conv?= =?UTF-8?q?ocatorias=20vigentes=20con=20API=20de=20admisi=C3=B3n=20y=20mej?= =?UTF-8?q?oras=20en=20el=20modal=20de=20detalles?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Administracion/ComunicadoController.php | 156 ++++++ back/app/Models/Comunicado.php | 28 + back/app/Models/ComunicadoImagen.php | 35 ++ ..._02_27_000001_create_comunicados_table.php | 25 + ...00002_create_comunicado_imagenes_table.php | 24 + ...10_add_url_accion_to_comunicados_table.php | 23 + back/routes/api.php | 16 +- front/src/components/WebPage.vue | 3 + .../WebPageSections/ComunicadoModal.vue | 368 +++++++++++++ .../WebPageSections/ConvocatoriasSection.vue | 2 + front/src/router/index.js | 5 + front/src/store/comunicadosStore.js | 128 +++++ .../comunicados/ComunicadosAdmin.vue | 493 ++++++++++++++++++ .../src/views/administrador/layout/layout.vue | 29 +- install-dev.md | 3 +- 15 files changed, 1330 insertions(+), 8 deletions(-) create mode 100644 back/app/Http/Controllers/Administracion/ComunicadoController.php create mode 100644 back/app/Models/Comunicado.php create mode 100644 back/app/Models/ComunicadoImagen.php create mode 100644 back/database/migrations/2026_02_27_000001_create_comunicados_table.php create mode 100644 back/database/migrations/2026_02_27_000002_create_comunicado_imagenes_table.php create mode 100644 back/database/migrations/2026_02_28_012810_add_url_accion_to_comunicados_table.php create mode 100644 front/src/components/WebPageSections/ComunicadoModal.vue create mode 100644 front/src/store/comunicadosStore.js create mode 100644 front/src/views/administrador/comunicados/ComunicadosAdmin.vue 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 @@ + + + + + + diff --git a/front/src/components/WebPageSections/ConvocatoriasSection.vue b/front/src/components/WebPageSections/ConvocatoriasSection.vue index 8d8fcba..0abc7fa 100644 --- a/front/src/components/WebPageSections/ConvocatoriasSection.vue +++ b/front/src/components/WebPageSections/ConvocatoriasSection.vue @@ -39,6 +39,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 @@ + + + + + diff --git a/front/src/views/administrador/layout/layout.vue b/front/src/views/administrador/layout/layout.vue index 7926e60..46ef41a 100644 --- a/front/src/views/administrador/layout/layout.vue +++ b/front/src/views/administrador/layout/layout.vue @@ -220,11 +220,18 @@ + + + + @@ -335,7 +342,10 @@ import { SafetyOutlined, ExportOutlined, HomeOutlined, + NotificationOutlined, + ReadOutlined, } from '@ant-design/icons-vue' +import NoticiasAdmin from '../procesoadmision/NoticiasAdmin.vue' const router = useRouter() const route = useRoute() @@ -432,6 +442,7 @@ const handleMenuSelect = ({ key }) => { 'procesos-lista': { name: 'ProcesosAdmisionList' }, 'noticias-lista': { name: 'NoticiasAdmisionList' }, + 'comunicados-lista': { name: 'ComunicadosAdmin' }, 'resultados': { name: 'AcademiaResultados' }, 'reportes': { name: 'AcademiaReportes' }, 'config-academia': { name: 'AcademiaConfig' }, @@ -481,11 +492,17 @@ const updatePageInfo = (key) => { title: 'cursos', subtitle: 'Lista de Cursos' }, - 'examenes-reglas-lista': { - section: 'Exámenes', - subSection: 'Reglas', - title: 'Reglas', - subtitle: 'Lista de Reglas' + 'examenes-reglas-lista': { + section: 'Exámenes', + subSection: 'Reglas', + title: 'Reglas', + subtitle: 'Lista de Reglas' + }, + 'comunicados-lista': { + section: 'WebConf', + subSection: 'Comunicados', + title: 'Comunicados', + subtitle: 'Gestión de comunicados de la web pública' } } diff --git a/install-dev.md b/install-dev.md index 666d923..ba3628a 100644 --- a/install-dev.md +++ b/install-dev.md @@ -66,7 +66,8 @@ Verificar tablas importadas: docker exec admision_2026_db mysql -uroot -proot admision_2026 -e "SHOW TABLES;" ``` -Debe mostrar 34 tablas (users, postulantes, areas, cursos, examenes, procesos_admision, etc.) +Debe mostrar 34 tablas base (users, postulantes, areas, cursos, examenes, procesos_admision, etc.). +Las tablas `comunicados` y `comunicado_imagenes` se crean en el paso 4.5 via migraciones. > **Nota:** El dump incluye la estructura de todas las tablas **y** los registros en `migrations`. > Aun así, siempre que hagas `git pull` debes correr `php artisan migrate` — el dump cubre las tablas