diff --git a/admision_2006-vI.sql b/admision_2006-vI.sql index 122af8d..dba075d 100644 --- a/admision_2006-vI.sql +++ b/admision_2006-vI.sql @@ -243,8 +243,16 @@ CREATE TABLE IF NOT EXISTS `migrations` ( PRIMARY KEY (`id`) ) ENGINE=InnoDB AUTO_INCREMENT=6 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; --- Volcando datos para la tabla admision_2026.migrations: ~0 rows (aproximadamente) +-- Volcando datos para la tabla admision_2026.migrations: ~7 rows (aproximadamente) DELETE FROM `migrations`; +INSERT INTO `migrations` (`migration`, `batch`) VALUES +('0001_01_01_000000_create_users_table', 1), +('0001_01_01_000001_create_cache_table', 1), +('0001_01_01_000002_create_jobs_table', 1), +('2026_01_27_132900_create_personal_access_tokens_table', 1), +('2026_01_27_133609_create_permission_tables', 1), +('2026_02_15_051618_fix_unique_constraint_proceso_admision_detalles', 1), +('2026_02_20_000001_create_proceso_admision_resultado_archivos_table', 2); -- Volcando estructura para tabla admision_2026.model_has_permissions CREATE TABLE IF NOT EXISTS `model_has_permissions` ( @@ -566,6 +574,23 @@ CREATE TABLE IF NOT EXISTS `proceso_admision_detalles` ( -- Volcando datos para la tabla admision_2026.proceso_admision_detalles: ~4 rows (aproximadamente) DELETE FROM `proceso_admision_detalles`; +-- Volcando estructura para tabla admision_2026.proceso_admision_resultado_archivos +CREATE TABLE IF NOT EXISTS `proceso_admision_resultado_archivos` ( + `id` bigint unsigned NOT NULL AUTO_INCREMENT, + `proceso_admision_id` bigint unsigned NOT NULL, + `nombre` varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL, + `file_path` varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL, + `orden` tinyint unsigned NOT NULL, + `created_at` timestamp NULL DEFAULT NULL, + `updated_at` timestamp NULL DEFAULT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `uniq_proceso_orden` (`proceso_admision_id`,`orden`), + CONSTRAINT `proceso_admision_resultado_archivos_proceso_admision_id_foreign` FOREIGN KEY (`proceso_admision_id`) REFERENCES `procesos_admision` (`id`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- Volcando datos para la tabla admision_2026.proceso_admision_resultado_archivos: ~0 rows (aproximadamente) +DELETE FROM `proceso_admision_resultado_archivos`; + -- Volcando estructura para tabla admision_2026.reglas_area_proceso CREATE TABLE IF NOT EXISTS `reglas_area_proceso` ( `id` bigint unsigned NOT NULL AUTO_INCREMENT, diff --git a/back/app/Http/Controllers/Administracion/ProcesoAdmisionController.php b/back/app/Http/Controllers/Administracion/ProcesoAdmisionController.php index 5967f36..ccef85b 100644 --- a/back/app/Http/Controllers/Administracion/ProcesoAdmisionController.php +++ b/back/app/Http/Controllers/Administracion/ProcesoAdmisionController.php @@ -156,6 +156,13 @@ class ProcesoAdmisionController extends Controller $data['slug'] = Str::slug($data['titulo'] ?? $proceso->titulo); } + // FormData envía strings vacíos; los convertimos a null para limpiar el campo en DB + foreach (['link_preinscripcion', 'link_inscripcion', 'link_resultados', 'link_reglamento'] as $key) { + if (array_key_exists($key, $data) && $data[$key] === '') { + $data[$key] = null; + } + } + if ($request->hasFile('imagen')) { if ($proceso->imagen_path) Storage::disk('public')->delete($proceso->imagen_path); $data['imagen_path'] = $request->file('imagen')->store('admisiones/procesos', 'public'); diff --git a/back/app/Http/Controllers/Administracion/ProcesoAdmisionResultadoArchivoController.php b/back/app/Http/Controllers/Administracion/ProcesoAdmisionResultadoArchivoController.php new file mode 100644 index 0000000..6579fba --- /dev/null +++ b/back/app/Http/Controllers/Administracion/ProcesoAdmisionResultadoArchivoController.php @@ -0,0 +1,71 @@ +orderBy('orden') + ->get(); + + return response()->json($archivos); + } + + public function store(Request $request, int $procesoId) + { + $request->validate([ + 'orden' => 'required|integer|min:1|max:6', + 'nombre' => 'required|string|max:255', + 'archivo' => 'required|file|mimes:txt|max:10240', + ]); + + // Máximo 6 archivos por proceso + $count = ProcesoAdmisionResultadoArchivo::where('proceso_admision_id', $procesoId)->count(); + if ($count >= 6) { + return response()->json(['message' => 'Máximo 6 archivos por proceso.'], 422); + } + + // No puede haber dos archivos con el mismo orden en el mismo proceso + $existe = ProcesoAdmisionResultadoArchivo::where('proceso_admision_id', $procesoId) + ->where('orden', $request->orden) + ->exists(); + if ($existe) { + return response()->json(['message' => 'Ya existe un archivo para ese slot.'], 422); + } + + $contenido = file_get_contents($request->file('archivo')->getRealPath()); + $encoding = mb_detect_encoding($contenido, ['UTF-8', 'Windows-1252', 'ISO-8859-1'], true); + $contenidoUtf8 = ($encoding === 'UTF-8') + ? $contenido + : mb_convert_encoding($contenido, 'UTF-8', $encoding ?: 'Windows-1252'); + $filename = uniqid() . '.txt'; + $path = "proceso-resultados/{$procesoId}/{$filename}"; + \Storage::disk('public')->put($path, $contenidoUtf8); + + $archivo = ProcesoAdmisionResultadoArchivo::create([ + 'proceso_admision_id' => $procesoId, + 'nombre' => $request->nombre, + 'file_path' => $path, + 'orden' => $request->orden, + ]); + + return response()->json($archivo, 201); + } + + public function destroy(int $id) + { + $archivo = ProcesoAdmisionResultadoArchivo::findOrFail($id); + + Storage::disk('public')->delete($archivo->file_path); + $archivo->delete(); + + return response()->json(['message' => 'Eliminado correctamente.']); + } +} diff --git a/back/app/Http/Controllers/WebController.php b/back/app/Http/Controllers/WebController.php index 9b7ca21..0f0cbe7 100644 --- a/back/app/Http/Controllers/WebController.php +++ b/back/app/Http/Controllers/WebController.php @@ -58,6 +58,7 @@ class WebController extends Controller ); } ]) + ->where('publicado', 1) ->latest() ->get(); diff --git a/back/app/Models/ProcesoAdmision.php b/back/app/Models/ProcesoAdmision.php index 106c862..a8a117f 100644 --- a/back/app/Models/ProcesoAdmision.php +++ b/back/app/Models/ProcesoAdmision.php @@ -66,6 +66,12 @@ class ProcesoAdmision extends Model return $this->hasMany(ResultadoAdmision::class, 'idproceso'); } + public function resultadoArchivos() + { + return $this->hasMany(ProcesoAdmisionResultadoArchivo::class, 'proceso_admision_id') + ->orderBy('orden'); + } + } diff --git a/back/app/Models/ProcesoAdmisionResultadoArchivo.php b/back/app/Models/ProcesoAdmisionResultadoArchivo.php new file mode 100644 index 0000000..7d9f327 --- /dev/null +++ b/back/app/Models/ProcesoAdmisionResultadoArchivo.php @@ -0,0 +1,36 @@ + 'integer', + ]; + + protected $appends = ['archivo_url']; + + public function getArchivoUrlAttribute(): ?string + { + return $this->file_path + ? Storage::disk('public')->url($this->file_path) + : null; + } + + public function proceso(): \Illuminate\Database\Eloquent\Relations\BelongsTo + { + return $this->belongsTo(ProcesoAdmision::class, 'proceso_admision_id'); + } +} diff --git a/back/database/migrations/2026_02_20_000001_create_proceso_admision_resultado_archivos_table.php b/back/database/migrations/2026_02_20_000001_create_proceso_admision_resultado_archivos_table.php new file mode 100644 index 0000000..9ee23d8 --- /dev/null +++ b/back/database/migrations/2026_02_20_000001_create_proceso_admision_resultado_archivos_table.php @@ -0,0 +1,29 @@ +id(); + $table->foreignId('proceso_admision_id') + ->constrained('procesos_admision') + ->onDelete('cascade'); + $table->string('nombre'); + $table->string('file_path'); + $table->unsignedTinyInteger('orden'); + $table->timestamps(); + + $table->unique(['proceso_admision_id', 'orden'], 'uniq_proceso_orden'); + }); + } + + public function down(): void + { + Schema::dropIfExists('proceso_admision_resultado_archivos'); + } +}; diff --git a/back/routes/api.php b/back/routes/api.php index e789920..f08369b 100644 --- a/back/routes/api.php +++ b/back/routes/api.php @@ -18,6 +18,7 @@ use App\Http\Controllers\Administracion\ProcesoAdmisionDetalleController; 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\WebController; Route::get('/user', function (Request $request) { @@ -196,4 +197,14 @@ Route::middleware('auth:sanctum')->prefix('admin')->group(function () { Route::get('/procesos-admision', [WebController::class, 'GetProcesoAdmision']); Route::get('/noticias', [NoticiaController::class, 'index']); -Route::get('/noticias/{noticia:slug}', [NoticiaController::class, 'showPublic']); \ No newline at end of file +Route::get('/noticias/{noticia:slug}', [NoticiaController::class, 'showPublic']); + +// Ruta pública: archivos de resultado del proceso vigente +Route::get('/proceso-resultado/{procesoId}/archivos', [ProcesoAdmisionResultadoArchivoController::class, 'index']); + +// Rutas admin: gestión de archivos de resultado +Route::middleware('auth:sanctum')->prefix('admin')->group(function () { + Route::get('/proceso-resultado/{procesoId}/archivos', [ProcesoAdmisionResultadoArchivoController::class, 'index']); + Route::post('/proceso-resultado/{procesoId}/archivos', [ProcesoAdmisionResultadoArchivoController::class, 'store']); + Route::delete('/proceso-resultado/archivos/{id}', [ProcesoAdmisionResultadoArchivoController::class, 'destroy']); +}); \ No newline at end of file diff --git a/front/src/components/WebPageSections/ConvocatoriasSection.vue b/front/src/components/WebPageSections/ConvocatoriasSection.vue index 59cc59f..8d8fcba 100644 --- a/front/src/components/WebPageSections/ConvocatoriasSection.vue +++ b/front/src/components/WebPageSections/ConvocatoriasSection.vue @@ -102,13 +102,30 @@ -
+ +
+
+

Resultados del Examen

+

Consulta los resultados del proceso de admisión vigente

+
+
+ + + Ver Resultados + +
+
+ + +

Preinscripción en Línea

Completa tu preinscripción de manera virtual y segura

- -
diff --git a/front/src/components/nabvar.vue b/front/src/components/nabvar.vue index 150f7c5..ecad3d3 100644 --- a/front/src/components/nabvar.vue +++ b/front/src/components/nabvar.vue @@ -475,9 +475,6 @@ onUnmounted(() => { .modern-header { padding: 0 12px !important; } - .logo-text span { - display: none; - } .mobile-menu-btn { font-size: 20px; padding: 6px; diff --git a/front/src/router/index.js b/front/src/router/index.js index e71d22c..e12e39d 100644 --- a/front/src/router/index.js +++ b/front/src/router/index.js @@ -29,6 +29,11 @@ const routes = [ path: '/resultados', name: 'Resultados', component: () => import('../components/WebPageSections/navbarcontent/Resultados.vue') + }, + { + path: '/proceso-resultado', + name: 'ProcesoResultado', + component: () => import('../components/WebPageSections/navbarcontent/ProcesoResultado.vue') }, { path: '/modalidades/cepreuna', diff --git a/front/src/store/procesoAdmisionResultado.store.js b/front/src/store/procesoAdmisionResultado.store.js new file mode 100644 index 0000000..af6939d --- /dev/null +++ b/front/src/store/procesoAdmisionResultado.store.js @@ -0,0 +1,113 @@ +import { defineStore } from 'pinia' +import api from '../axios' +import apiPublico from '../axiosPostulante' + +// Genera el nombre predeterminado para cada slot según las fechas del proceso. +// El admin puede editarlo antes de subir, pero esto es el punto de partida. +export function generarNombreSlot(orden, proceso) { + const titulo = proceso?.titulo ?? 'Examen' + + const formatDia = (isoStr, abrev = false) => { + if (!isoStr) return '' + const d = new Date(isoStr) + if (Number.isNaN(d.getTime())) return '' + const dias = ['domingo', 'lunes', 'martes', 'miércoles', 'jueves', 'viernes', 'sábado'] + const diasAbrev = ['Dom.', 'Lun.', 'Mar.', 'Mié.', 'Jue.', 'Vie.', 'Sáb.'] + const meses = ['enero','febrero','marzo','abril','mayo','junio','julio','agosto','septiembre','octubre','noviembre','diciembre'] + const nombreDia = abrev ? diasAbrev[d.getDay()] : dias[d.getDay()] + const dia = String(d.getDate()).padStart(2, '0') + const mes = meses[d.getMonth()] + const anio = d.getFullYear() + return abrev + ? `${nombreDia} ${d.getDate()} ${mes.charAt(0).toUpperCase() + mes.slice(1)}` + : `${nombreDia} ${dia} de ${mes}` + } + + const f1 = formatDia(proceso?.fecha_examen1) + const f2 = formatDia(proceso?.fecha_examen2) + const f2abrev = formatDia(proceso?.fecha_examen2, true) + const anio2 = proceso?.fecha_examen2 ? new Date(proceso.fecha_examen2).getFullYear() : '' + + const plantillas = { + 1: `Ingresantes ${titulo} ${f1}`, + 2: `Ingresantes ${titulo} ${f1} (CONADIS)`, + 3: `Clasificados para el examen del ${f2} del ${anio2}`, + 4: `Clasificados para la 2da etapa del ${f2} (CONADIS)`, + 5: `Ingresantes ${titulo} ${f2}`, + 6: `Ingresantes ${titulo} ${f2} (CONADIS)`, + } + + return plantillas[orden] ?? `Resultado ${orden}` +} + +export const useProcesoAdmisionResultadoStore = defineStore('procesoAdmisionResultado', { + state: () => ({ + archivos: [], + loading: false, + error: null, + }), + + actions: { + // Uso público: HeroSection + ProcesoResultado.vue + async fetchArchivosPublico(procesoId) { + this.loading = true + this.error = null + try { + const res = await apiPublico.get(`/proceso-resultado/${procesoId}/archivos`) + this.archivos = res.data ?? [] + } catch (err) { + this.error = err.response?.data?.message ?? 'Error al cargar archivos' + this.archivos = [] + } finally { + this.loading = false + } + }, + + // Uso admin: modal en ProcesosAdmisionList + async fetchArchivosAdmin(procesoId) { + this.loading = true + this.error = null + try { + const res = await api.get(`/admin/proceso-resultado/${procesoId}/archivos`) + this.archivos = res.data ?? [] + } catch (err) { + this.error = err.response?.data?.message ?? 'Error al cargar archivos' + this.archivos = [] + } finally { + this.loading = false + } + }, + + async subirArchivo(procesoId, formData) { + this.error = null + try { + const res = await api.post( + `/admin/proceso-resultado/${procesoId}/archivos`, + formData, + { headers: { 'Content-Type': 'multipart/form-data' } } + ) + this.archivos = [...this.archivos, res.data].sort((a, b) => a.orden - b.orden) + return true + } catch (err) { + this.error = err.response?.data?.message ?? 'Error al subir archivo' + return false + } + }, + + async eliminarArchivo(id) { + try { + await api.delete(`/admin/proceso-resultado/archivos/${id}`) + this.archivos = this.archivos.filter((a) => a.id !== id) + return true + } catch (err) { + this.error = err.response?.data?.message ?? 'Error al eliminar' + return false + } + }, + + limpiar() { + this.archivos = [] + this.error = null + }, + }, +}) diff --git a/front/src/store/web.js b/front/src/store/web.js index 7899991..a5a5576 100644 --- a/front/src/store/web.js +++ b/front/src/store/web.js @@ -12,8 +12,7 @@ export const useWebAdmisionStore = defineStore("procesoAdmision", { getters: { // Si hay uno VIGENTE, úsalo como principal; si no, usa el primero. procesoPrincipal: (state) => { - if (!state.procesos?.length) return null - return state.procesos.find((p) => p.estado === "publicado") ?? state.procesos[0] + return state.procesos?.[0] ?? null }, // Por si lo necesitas después diff --git a/front/src/views/administrador/procesoadmision/ProcesosAdmisionList.vue b/front/src/views/administrador/procesoadmision/ProcesosAdmisionList.vue index f68495c..14c0ad2 100644 --- a/front/src/views/administrador/procesoadmision/ProcesosAdmisionList.vue +++ b/front/src/views/administrador/procesoadmision/ProcesosAdmisionList.vue @@ -128,6 +128,9 @@