diff --git a/back/app/Http/Controllers/Administracion/UserController.php b/back/app/Http/Controllers/Administracion/UserController.php
new file mode 100644
index 0000000..8cf8a00
--- /dev/null
+++ b/back/app/Http/Controllers/Administracion/UserController.php
@@ -0,0 +1,237 @@
+filled('buscar')) {
+ $query->where(function ($q) use ($request) {
+ $q->where('name', 'like', "%{$request->buscar}%")
+ ->orWhere('email', 'like', "%{$request->buscar}%");
+ });
+ }
+
+ if ($request->filled('rol')) {
+ $query->whereHas('roles', function ($q) use ($request) {
+ $q->where('name', $request->rol);
+ });
+ }
+
+ $usuarios = $query->orderBy('id', 'desc')->paginate(15);
+
+ $usuarios->getCollection()->transform(function ($user) {
+ return [
+ 'id' => $user->id,
+ 'name' => $user->name,
+ 'email' => $user->email,
+ 'roles' => $user->getRoleNames(),
+ 'created_at' => $user->created_at,
+ ];
+ });
+
+ return response()->json([
+ 'success' => true,
+ 'data' => $usuarios,
+ ]);
+ } catch (\Exception $e) {
+ Log::error('Error al listar usuarios', ['error' => $e->getMessage()]);
+ return response()->json(['success' => false, 'message' => 'Error al obtener usuarios'], 500);
+ }
+ }
+
+ public function store(Request $request)
+ {
+ try {
+ $validator = Validator::make($request->all(), [
+ 'name' => 'required|string|max:255|regex:/^[\pL\s\-]+$/u',
+ 'email' => 'required|email|unique:users,email|max:255',
+ 'password' => 'required|string|min:8|confirmed',
+ 'rol' => 'required|string|exists:roles,name',
+ ], [
+ 'name.regex' => 'El nombre solo puede contener letras y espacios.',
+ 'rol.exists' => 'El rol seleccionado no existe.',
+ 'email.unique' => 'Este correo ya está registrado.',
+ ]);
+
+ if ($validator->fails()) {
+ return response()->json(['success' => false, 'errors' => $validator->errors()], 422);
+ }
+
+ $user = User::create([
+ 'name' => strip_tags(trim($request->name)),
+ 'email' => strtolower(trim($request->email)),
+ 'password' => Hash::make($request->password),
+ ]);
+
+ $user->assignRole($request->rol);
+
+ Log::info('Usuario creado por admin', [
+ 'admin_id' => $request->user()->id,
+ 'nuevo_user_id' => $user->id,
+ ]);
+
+ return response()->json([
+ 'success' => true,
+ 'message' => 'Usuario creado correctamente',
+ 'data' => [
+ 'id' => $user->id,
+ 'name' => $user->name,
+ 'email' => $user->email,
+ 'roles' => $user->getRoleNames(),
+ 'created_at' => $user->created_at,
+ ],
+ ], 201);
+ } catch (\Exception $e) {
+ Log::error('Error al crear usuario', ['error' => $e->getMessage()]);
+ return response()->json(['success' => false, 'message' => 'Error al crear usuario'], 500);
+ }
+ }
+
+ public function update(Request $request, $id)
+ {
+ try {
+ $user = User::findOrFail($id);
+
+ $validator = Validator::make($request->all(), [
+ 'name' => 'required|string|max:255|regex:/^[\pL\s\-]+$/u',
+ 'email' => 'required|email|max:255|unique:users,email,' . $user->id,
+ 'rol' => 'required|string|exists:roles,name',
+ ], [
+ 'name.regex' => 'El nombre solo puede contener letras y espacios.',
+ 'rol.exists' => 'El rol seleccionado no existe.',
+ 'email.unique' => 'Este correo ya está registrado.',
+ ]);
+
+ if ($validator->fails()) {
+ return response()->json(['success' => false, 'errors' => $validator->errors()], 422);
+ }
+
+ $user->update([
+ 'name' => strip_tags(trim($request->name)),
+ 'email' => strtolower(trim($request->email)),
+ ]);
+
+ $user->syncRoles([$request->rol]);
+
+ Log::info('Usuario actualizado', [
+ 'admin_id' => $request->user()->id,
+ 'user_id' => $user->id,
+ ]);
+
+ return response()->json([
+ 'success' => true,
+ 'message' => 'Usuario actualizado correctamente',
+ 'data' => [
+ 'id' => $user->id,
+ 'name' => $user->name,
+ 'email' => $user->email,
+ 'roles' => $user->getRoleNames(),
+ 'created_at' => $user->created_at,
+ ],
+ ]);
+ } catch (\Exception $e) {
+ Log::error('Error al actualizar usuario', ['error' => $e->getMessage()]);
+ return response()->json(['success' => false, 'message' => 'Error al actualizar usuario'], 500);
+ }
+ }
+
+ public function changePassword(Request $request, $id)
+ {
+ try {
+ $user = User::findOrFail($id);
+ $authUser = $request->user();
+
+ $rules = ['password' => 'required|string|min:8|confirmed'];
+
+ if ($authUser->id === $user->id) {
+ $rules['current_password'] = 'required|string';
+ }
+
+ $validator = Validator::make($request->all(), $rules);
+
+ if ($validator->fails()) {
+ return response()->json(['success' => false, 'errors' => $validator->errors()], 422);
+ }
+
+ if ($authUser->id === $user->id) {
+ if (!Hash::check($request->current_password, $user->password)) {
+ return response()->json([
+ 'success' => false,
+ 'errors' => ['current_password' => ['La contraseña actual no es correcta']],
+ ], 422);
+ }
+ } else {
+ // Al cambiar la contraseña de otro usuario, revocar sus tokens
+ $user->tokens()->delete();
+ }
+
+ $user->update(['password' => Hash::make($request->password)]);
+
+ Log::info('Contraseña cambiada', [
+ 'admin_id' => $authUser->id,
+ 'user_id' => $user->id,
+ ]);
+
+ return response()->json([
+ 'success' => true,
+ 'message' => 'Contraseña actualizada correctamente',
+ ]);
+ } catch (\Exception $e) {
+ Log::error('Error al cambiar contraseña', ['error' => $e->getMessage()]);
+ return response()->json(['success' => false, 'message' => 'Error al cambiar contraseña'], 500);
+ }
+ }
+
+ public function destroy(Request $request, $id)
+ {
+ try {
+ $user = User::findOrFail($id);
+
+ if ($request->user()->id === $user->id) {
+ return response()->json([
+ 'success' => false,
+ 'message' => 'No puedes eliminar tu propia cuenta',
+ ], 403);
+ }
+
+ $user->tokens()->delete();
+ $user->delete();
+
+ Log::info('Usuario eliminado', [
+ 'admin_id' => $request->user()->id,
+ 'user_id' => $id,
+ ]);
+
+ return response()->json([
+ 'success' => true,
+ 'message' => 'Usuario eliminado correctamente',
+ ]);
+ } catch (\Exception $e) {
+ Log::error('Error al eliminar usuario', ['error' => $e->getMessage()]);
+ return response()->json(['success' => false, 'message' => 'Error al eliminar usuario'], 500);
+ }
+ }
+
+ public function roles()
+ {
+ try {
+ $roles = Role::orderBy('name')->get(['id', 'name']);
+ return response()->json(['success' => true, 'data' => $roles]);
+ } catch (\Exception $e) {
+ return response()->json(['success' => false, 'message' => 'Error al obtener roles'], 500);
+ }
+ }
+}
diff --git a/back/routes/api.php b/back/routes/api.php
index 3c1476c..a328cd3 100644
--- a/back/routes/api.php
+++ b/back/routes/api.php
@@ -21,6 +21,7 @@ use App\Http\Controllers\Administracion\NoticiaController;
use App\Http\Controllers\Administracion\ProcesoAdmisionResultadoArchivoController;
use App\Http\Controllers\Administracion\ComunicadoController;
use App\Http\Controllers\WebController;
+use App\Http\Controllers\Administracion\UserController;
Route::get('/user', function (Request $request) {
return $request->user();
@@ -228,4 +229,14 @@ Route::middleware('auth:sanctum')->prefix('admin')->group(function () {
Route::delete('/comunicados/imagenes/{imagenId}', [ComunicadoController::class, 'destroyImagen']);
});
-Route::middleware('auth:sanctum')->get('/postulante/observacion', [PostulanteAuthController::class, 'miObservacion']);
\ No newline at end of file
+Route::middleware('auth:sanctum')->get('/postulante/observacion', [PostulanteAuthController::class, 'miObservacion']);
+
+// Admin: gestión de usuarios
+Route::middleware('auth:sanctum')->prefix('admin')->group(function () {
+ Route::get('/roles', [UserController::class, 'roles']);
+ Route::get('/usuarios', [UserController::class, 'index']);
+ Route::post('/usuarios', [UserController::class, 'store']);
+ Route::put('/usuarios/{id}', [UserController::class, 'update']);
+ Route::patch('/usuarios/{id}/cambiar-password', [UserController::class, 'changePassword']);
+ Route::delete('/usuarios/{id}', [UserController::class, 'destroy']);
+});
\ No newline at end of file
diff --git a/front/src/router/index.js b/front/src/router/index.js
index 429f366..374564f 100644
--- a/front/src/router/index.js
+++ b/front/src/router/index.js
@@ -214,6 +214,11 @@ const routes = [
path: '/admin/dashboard/comunicados',
name: 'ComunicadosAdmin',
component: () => import('../views/administrador/comunicados/ComunicadosAdmin.vue')
+ },
+ {
+ path: '/admin/dashboard/usuarios',
+ name: 'UsuariosList',
+ component: () => import('../views/administrador/usuarios/UsuariosList.vue')
}
]
diff --git a/front/src/store/usuariosStore.js b/front/src/store/usuariosStore.js
new file mode 100644
index 0000000..0a18dfd
--- /dev/null
+++ b/front/src/store/usuariosStore.js
@@ -0,0 +1,64 @@
+import { defineStore } from 'pinia'
+import api from '../axios'
+
+export const useUsuariosStore = defineStore('usuarios', {
+ state: () => ({
+ usuarios: [],
+ roles: [],
+ loading: false,
+ pagination: {
+ current: 1,
+ pageSize: 15,
+ total: 0,
+ },
+ }),
+
+ actions: {
+ async fetchUsuarios(params = {}) {
+ this.loading = true
+ try {
+ const response = await api.get('/admin/usuarios', { params })
+ const { data } = response.data
+ this.usuarios = data.data
+ this.pagination = {
+ current: data.current_page,
+ pageSize: data.per_page,
+ total: data.total,
+ }
+ } catch (error) {
+ throw error
+ } finally {
+ this.loading = false
+ }
+ },
+
+ async fetchRoles() {
+ try {
+ const response = await api.get('/admin/roles')
+ this.roles = response.data.data
+ } catch (error) {
+ throw error
+ }
+ },
+
+ async createUsuario(payload) {
+ const response = await api.post('/admin/usuarios', payload)
+ return response.data
+ },
+
+ async updateUsuario(id, payload) {
+ const response = await api.put(`/admin/usuarios/${id}`, payload)
+ return response.data
+ },
+
+ async changePassword(id, payload) {
+ const response = await api.patch(`/admin/usuarios/${id}/cambiar-password`, payload)
+ return response.data
+ },
+
+ async deleteUsuario(id) {
+ const response = await api.delete(`/admin/usuarios/${id}`)
+ return response.data
+ },
+ },
+})
diff --git a/front/src/views/administrador/layout/layout.vue b/front/src/views/administrador/layout/layout.vue
index 993ba56..7655e4e 100644
--- a/front/src/views/administrador/layout/layout.vue
+++ b/front/src/views/administrador/layout/layout.vue
@@ -432,7 +432,7 @@ const handleMenuSelect = ({ key }) => {
'resultados': { name: 'AcademiaResultados' },
'reportes': { name: 'AcademiaReportes' },
'config-academia': { name: 'AcademiaConfig' },
- 'usuarios': { name: 'AcademiaUsuarios' }
+ 'usuarios': { name: 'UsuariosList' }
}
if (routes[key]) {
diff --git a/front/src/views/administrador/usuarios/UsuariosList.vue b/front/src/views/administrador/usuarios/UsuariosList.vue
new file mode 100644
index 0000000..e92495d
--- /dev/null
+++ b/front/src/views/administrador/usuarios/UsuariosList.vue
@@ -0,0 +1,540 @@
+
+
+
+
+
+
+
+
+
+
+
+ {{ rol.name }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ iniciales(record.name) }}
+
+
{{ record.name }}
+
+
+
+
+
+
+ {{ rol }}
+
+ Sin rol
+
+
+
+
+ {{ formatDate(record.created_at) }}
+
+
+
+
+
+
+
+ Editar
+
+
+
+ Contraseña
+
+
+
+
+ Eliminar
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ rol.name }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ rol.name }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/temporal_revision_tecnica.md b/temporal_revision_tecnica.md
new file mode 100644
index 0000000..d29e7b9
--- /dev/null
+++ b/temporal_revision_tecnica.md
@@ -0,0 +1,704 @@
+# ANÁLISIS TÉCNICO EXHAUSTIVO - PROYECTO DIRECCIÓN DE ADMISIÓN 2026
+
+> Generado: 2026-04-08
+
+---
+
+## 1. ESTRUCTURA GENERAL DEL PROYECTO
+
+```
+/direccion_de_admision_2026/
+├── back/ # Backend Laravel 12
+│ ├── app/
+│ │ ├── Models/ # 21 modelos Eloquent
+│ │ ├── Http/Controllers/ # Controladores REST
+│ │ └── Services/ # Lógica de negocio
+│ ├── database/
+│ │ ├── migrations/ # 9 migraciones
+│ │ └── seeders/
+│ ├── routes/api.php # Definición de endpoints API
+│ ├── config/ # Configuraciones
+│ ├── composer.json # Dependencias PHP
+│ └── Dockerfile # Multi-stage build
+├── front/ # Frontend Vue 3 + Vite
+│ ├── src/
+│ │ ├── views/ # Vistas por rol (admin, postulante)
+│ │ ├── components/ # Componentes reutilizables
+│ │ ├── store/ # 14 stores Pinia
+│ │ ├── router/index.js # Rutas y guards
+│ │ └── axios.js # Instancia HTTP autenticada
+│ ├── package.json
+│ ├── vite.config.js
+│ └── Dockerfile
+├── nginx/ # Configuración Nginx
+├── mysql/ # Configuración MySQL
+├── docker-compose.prod.yml # Orquestación producción
+├── docker-compose.yml # Desarrollo local
+└── .github/workflows/ # CI/CD
+```
+
+---
+
+## 2. STACK TÉCNICO - VERSIONES EXACTAS
+
+### Backend (Laravel 12)
+
+| Librería | Versión | Propósito |
+|----------|---------|----------|
+| **laravel/framework** | ^12.0 | Framework principal |
+| **laravel/sanctum** | ^4.2 | API tokens + autenticación |
+| **spatie/laravel-permission** | ^6.24 | Gestión de roles/permisos |
+| **simplesoftwareio/simple-qrcode** | ^4.2 | Generación códigos QR |
+| **laravel/tinker** | ^2.10.1 | REPL para desarrollo |
+
+**PHP:** ^8.2 (Dockerfile especifica 8.4-fpm-alpine)
+**Base de datos:** MySQL 8.0
+**ORM:** Eloquent (nativo)
+**Autenticación:** Laravel Sanctum (tokens bearer)
+
+### Frontend (Vue 3)
+
+| Librería | Versión | Propósito |
+|----------|---------|----------|
+| **vue** | ^3.5.24 | Framework frontend |
+| **vue-router** | ^4.6.4 | Enrutamiento SPA |
+| **pinia** | ^3.0.4 | State management |
+| **ant-design-vue** | ^4.2.6 | Componentes UI |
+| **axios** | ^1.13.3 | Cliente HTTP |
+| **chart.js** | ^4.5.1 | Gráficos |
+| **vue-chartjs** | ^5.3.3 | Binding Vue para Chart.js |
+| **@vueup/vue-quill** | ^1.2.0 | Editor WYSIWYG |
+| **marked** | ^17.0.1 | Parser markdown |
+| **markdown-it** | ^14.1.0 | Markdown avanzado |
+| **dayjs** | ^1.11.19 | Utilidades fecha/hora |
+| **vue-qrcode** | ^2.2.2 | Generador QR |
+| **katex** | ^0.16.28 | Renderizado LaTeX |
+
+**Build Tool:** Vite 7.2.4
+**Package Manager:** npm
+
+---
+
+## 3. BACKEND - ARQUITECTURA LARAVEL 12
+
+### 3.1 Modelos y Relaciones (21 modelos)
+
+#### Modelos Núcleo de Admisión
+
+| Modelo | Tabla | Relaciones Clave | Propósito |
+|--------|-------|------------------|----------|
+| **ProcesoAdmision** | procesos_admision | hasMany(ProcesoAdmisionDetalle), hasMany(ProcesoAdmisionResultadoArchivo), hasMany(ResultadoAdmision) | Gestiona procesos de admisión (convocatorias) |
+| **ProcesoAdmisionDetalle** | proceso_admision_detalles | belongsTo(ProcesoAdmision) | Detalles informativos de procesos (requisitos, cronograma, etc.) |
+| **ProcesoAdmisionResultadoArchivo** | proceso_admision_resultado_archivos | belongsTo(ProcesoAdmision) | Archivos de resultados organizados por slot/sedes |
+| **ResultadoAdmision** | resultados_admision | belongsTo(ProcesoAdmision), belongsTo(AreaAdmision) | Resultados finales de admisión de postulantes |
+| **Proceso** | procesos | belongsTo(Calificacion), belongsToMany(Area, via area_proceso) | Procesos de examen asociados a áreas |
+| **Area** | areas | belongsToMany(Curso), belongsToMany(Examen), belongsToMany(Proceso) | Áreas temáticas (Biomedicas, Ingeniería) |
+| **Curso** | cursos | belongsToMany(Area), hasMany(Pregunta) | Cursos dentro de áreas (Matemática, Comunicación) |
+| **Pregunta** | preguntas | belongsTo(Curso) | Preguntas de examen con opciones múltiples |
+| **PreguntaAsignada** | preguntas_asignadas | belongsTo(Examen) | Asociación pregunta-examen |
+
+#### Modelos de Usuarios
+
+| Modelo | Tabla | Relaciones | Propósito |
+|--------|-------|-----------|----------|
+| **User** | users | belongsToMany(Academia), hasMany(IntentoExamen) | Administradores/Staff (usa Spatie roles) |
+| **Postulante** | postulantes | hasMany(Examen) | Postulantes que rinden exámenes |
+| **AreaAdmision** | areas_admision | hasMany(ResultadoAdmision) | Áreas en contexto de admisión (diferente a Area de exámenes) |
+
+#### Modelos de Evaluación
+
+| Modelo | Tabla | Relaciones | Propósito |
+|--------|-------|-----------|----------|
+| **Examen** | examenes | belongsTo(Postulante), belongsTo(AreaProceso), belongsTo(Pago), hasMany(PreguntaAsignada) | Examen renderizado por postulante |
+| **Calificacion** | calificaciones | hasMany(Proceso) | Esquemas de puntuación (puntos por correcta, incorrecta, nula) |
+| **ResultadoExamen** | resultados_examenes | — | Resultados parciales de exámenes |
+| **Pago** | pagos | belongsTo(Postulante) | Pagos de procesos que requieren arancel |
+
+#### Modelos de Contenido
+
+| Modelo | Tabla | Relaciones | Propósito |
+|--------|-------|-----------|----------|
+| **Noticia** | noticias | Standalone (SoftDeletes) | Noticias publicables en web pública |
+| **Comunicado** | comunicados | hasMany(ComunicadoImagen) | Comunicados activos con imágenes |
+| **ComunicadoImagen** | comunicado_imagenes | belongsTo(Comunicado) | Imágenes asociadas a comunicados |
+
+#### Modelos Intermedios y Apoyo
+
+| Modelo | Tabla | Propósito |
+|--------|-------|----------|
+| **ReglaAreaProceso** | reglas_area_proceso | Define reglas de asignación de preguntas por área-proceso |
+| **ResultadoAdmisionCarga** | resultados_admision_carga | Almacena detalles de cursos en resultados de admisión |
+
+### 3.2 Esquema de Base de Datos (30+ tablas)
+
+```
+areas -- Catálogo de áreas (Biomedicas, Ingenierías)
+cursos -- Catálogo de cursos (Matemática, Comunicación)
+area_curso -- M2M: área-curso
+procesos -- Procesos de examen
+area_proceso -- M2M: área-proceso (pivot con ID)
+preguntas -- Preguntas de examen
+preguntas_asignadas -- Preguntas asignadas a examen del postulante
+examenes -- Exámenes rendidos
+postulantes -- Usuarios que rinden exámenes
+pagos -- Control de pagos
+resultados_examenes -- Resultados de exámenes
+
+procesos_admision -- Procesos de admisión (convocatorias)
+proceso_admision_detalles -- Detalles de procesos (requisitos, cronograma)
+proceso_admision_resultado_archivos -- Archivos de resultados por sede/slot
+resultados_admision -- Resultados de admisión
+areas_admision -- Áreas de admisión
+
+noticias -- Artículos publicables
+comunicados -- Comunicados activos
+comunicado_imagenes -- Imágenes en comunicados
+
+users -- Administradores
+roles, permissions -- Spatie permission tables
+model_has_roles, model_has_permissions
+personal_access_tokens -- Tokens Sanctum
+```
+
+**Restricciones de Integridad:**
+- `ON DELETE CASCADE` en relaciones examenes → postulantes, preguntas_asignadas → examenes
+- `UNIQUE CONSTRAINTS` en `area_proceso(area_id, proceso_id)` y `area_curso`
+- `UNIQUE KEY` en `proceso_admision_resultado_archivos(proceso_admision_id, orden)`
+
+---
+
+## 4. API REST - ENDPOINTS COMPLETOS (73 rutas)
+
+### Autenticación Admin
+```
+POST /api/register
+POST /api/login
+POST /api/logout [auth:sanctum]
+GET /api/me [auth:sanctum]
+POST /api/refresh-token [auth:sanctum]
+GET /api/user [auth:sanctum]
+```
+
+### Autenticación Postulante
+```
+POST /api/postulante/register
+POST /api/postulante/login
+POST /api/postulante/logout [auth:sanctum]
+GET /api/postulante/me [auth:sanctum]
+GET /api/postulante/pagos [auth:sanctum]
+GET /api/postulante/mis-procesos [auth:sanctum]
+GET /api/postulante/observacion [auth:sanctum]
+GET /api/mis-procesos/{id}/avance [auth:sanctum]
+```
+
+### Procesos de Examen
+```
+GET /api/procesos [auth:sanctum]
+POST /api/procesos [auth:sanctum]
+GET /api/procesos/{id} [auth:sanctum]
+PUT /api/procesos/{id} [auth:sanctum]
+PATCH /api/procesos/{id}/toggle-activo [auth:sanctum]
+DELETE /api/procesos/{id} [auth:sanctum]
+```
+
+### Admin - Áreas
+```
+GET /api/admin/areas [auth:sanctum]
+POST /api/admin/areas [auth:sanctum]
+GET /api/admin/areas/{id} [auth:sanctum]
+PUT /api/admin/areas/{id} [auth:sanctum]
+DELETE /api/admin/areas/{id} [auth:sanctum]
+PATCH /api/admin/areas/{id}/toggle [auth:sanctum]
+POST /api/admin/areas/{area}/vincular-cursos [auth:sanctum]
+POST /api/admin/areas/{area}/desvincular-curso [auth:sanctum]
+GET /api/admin/areas/{area}/cursos-disponibles [auth:sanctum]
+POST /api/admin/areas/{area}/vincular-procesos [auth:sanctum]
+GET /api/admin/areas/{area}/procesos-disponibles [auth:sanctum]
+POST /api/admin/areas/{area}/desvincular-procesos [auth:sanctum]
+```
+
+### Admin - Cursos
+```
+GET /api/admin/cursos [auth:sanctum]
+POST /api/admin/cursos [auth:sanctum]
+GET /api/admin/cursos/{id} [auth:sanctum]
+PUT /api/admin/cursos/{id} [auth:sanctum]
+DELETE /api/admin/cursos/{id} [auth:sanctum]
+PATCH /api/admin/cursos/{id}/toggle [auth:sanctum]
+```
+
+### Admin - Preguntas
+```
+GET /api/admin/cursos/{id}/preguntas [auth:sanctum]
+POST /api/admin/preguntas [auth:sanctum]
+GET /api/admin/preguntas/{id} [auth:sanctum]
+PUT /api/admin/preguntas/{id} [auth:sanctum]
+DELETE /api/admin/preguntas/{id} [auth:sanctum]
+```
+
+### Admin - Noticias
+```
+GET /api/admin/noticias [auth:sanctum]
+GET /api/admin/noticias/{id} [auth:sanctum]
+POST /api/admin/noticias [auth:sanctum]
+PUT /api/admin/noticias/{id} [auth:sanctum]
+DELETE /api/admin/noticias/{id} [auth:sanctum]
+```
+
+### Admin - Procesos de Admisión
+```
+GET /api/admin/procesos-admision [auth:sanctum]
+POST /api/admin/procesos-admision [auth:sanctum]
+GET /api/admin/procesos-admision/{id} [auth:sanctum]
+PUT/PATCH /api/admin/procesos-admision/{id} [auth:sanctum]
+DELETE /api/admin/procesos-admision/{id} [auth:sanctum]
+
+GET /api/admin/procesos-admision/{id}/detalles [auth:sanctum]
+POST /api/admin/procesos-admision/{id}/detalles [auth:sanctum]
+GET /api/admin/detalles-admision/{id} [auth:sanctum]
+PUT/PATCH /api/admin/detalles-admision/{id} [auth:sanctum]
+DELETE /api/admin/detalles-admision/{id} [auth:sanctum]
+```
+
+### Admin - Resultados (Archivos)
+```
+GET /api/admin/proceso-resultado/{id}/archivos [auth:sanctum]
+POST /api/admin/proceso-resultado/{id}/archivos [auth:sanctum]
+DELETE /api/admin/proceso-resultado/archivos/{id} [auth:sanctum]
+GET /api/proceso-resultado/{id}/archivos [público]
+```
+
+### Admin - Comunicados
+```
+GET /api/admin/comunicados [auth:sanctum]
+POST /api/admin/comunicados [auth:sanctum]
+PUT/PATCH /api/admin/comunicados/{id} [auth:sanctum]
+DELETE /api/admin/comunicados/{id} [auth:sanctum]
+PATCH /api/admin/comunicados/{id}/toggle-activo [auth:sanctum]
+DELETE /api/admin/comunicados/imagenes/{id} [auth:sanctum]
+```
+
+### Examen Online
+```
+GET /api/examen/procesos [auth:sanctum]
+GET /api/examen/areas [auth:sanctum]
+GET /api/examen/actual [auth:sanctum]
+POST /api/examen/crear [auth:sanctum]
+POST /api/examen/{id}/generar-preguntas [auth:sanctum]
+GET /api/examen/{id}/preguntas [auth:sanctum]
+POST /api/examen/iniciar [auth:sanctum]
+POST /api/examen/pregunta/{id}/responder [auth:sanctum]
+POST /api/examen/{id}/finalizar [auth:sanctum]
+POST /api/examen/{id}/calificar [auth:sanctum]
+```
+
+### Reglas de Examen
+```
+GET /api/area-proceso/areasprocesos [auth:sanctum]
+GET /api/area-proceso/{id}/reglas [auth:sanctum]
+POST /api/area-proceso/{id}/reglas [auth:sanctum]
+POST /api/area-proceso/{id}/reglas/multiple [auth:sanctum]
+PUT /api/reglas/{id} [auth:sanctum]
+DELETE /api/reglas/{id} [auth:sanctum]
+```
+
+### Calificaciones
+```
+GET /api/calificaciones [auth:sanctum]
+POST /api/calificaciones [auth:sanctum]
+GET /api/calificaciones/{id} [auth:sanctum]
+PUT /api/calificaciones/{id} [auth:sanctum]
+DELETE /api/calificaciones/{id} [auth:sanctum]
+```
+
+### Postulantes (Admin)
+```
+GET /api/admin/postulantes [auth:sanctum]
+PUT /api/admin/postulantes/{id} [auth:sanctum]
+```
+
+### Web Pública
+```
+GET /api/procesos-admision [público]
+GET /api/noticias [público]
+GET /api/noticias/{slug} [público]
+GET /api/comunicados/activo [público]
+GET /api/procesos-disponibles-preinscripcion [auth:sanctum]
+```
+
+---
+
+## 5. AUTENTICACIÓN Y AUTORIZACIÓN
+
+### Flujo Admin
+1. `POST /api/login` con email/password
+2. `AuthController` verifica credenciales, revoca tokens previos
+3. Genera token con `createToken('api_token', ['*'], now()->addHours(12))`
+4. Devuelve token + user + roles/permisos (Spatie)
+
+### Flujo Postulante
+1. `POST /api/postulante/login`
+2. Token con expiración 1 hora
+3. `Device-Id` único para detección multi-dispositivo
+4. Guard separado: `postulante_token` en localStorage
+
+### Roles (Spatie Permission)
+- `administrador` — acceso a panel admin
+- `superadmin` — acceso extendido
+- Permisos granulares via `model_has_permissions`
+
+---
+
+## 6. FRONTEND - ARQUITECTURA VUE 3
+
+### 6.1 Rutas (23 rutas principales)
+
+```
+/ (público) → WebPage (SPA pública)
+/login-postulante → LoginView
+/proceso-resultado → ProcesoResultado
+/modalidades/cepreuna → Cepreuna
+/modalidades/extraordinario → Extraordinario
+/modalidades/general → General
+
+/portal-postulante (requiresAuth)
+ ├─ / → Dashboard postulante
+ ├─ /test → Selector proceso/área
+ ├─ /examen/:examenId → PreguntasExamen (examen online)
+ ├─ /resultados/:examenId → Resultados (calificaciones)
+ ├─ /mis-procesos → MisProcesos (admisión)
+ └─ /mis-procesos-estado → AvanceProceso
+
+/admin/dashboard (requiresAuth + role:administrador)
+ ├─ / → Dashboard admin
+ ├─ /areas → AreasList (CRUD)
+ ├─ /cursos → CursosList (CRUD)
+ ├─ /cursos/:id/preguntas → PreguntasCursoView
+ ├─ /procesos → ProcesosList (CRUD)
+ ├─ /reglas → ReglasList (CRUD)
+ ├─ /procesos-admision → ProcesosAdmisionList (CRUD)
+ ├─ /procesos/:id/detalles → ProcesoAdmisionDetalles
+ ├─ /lista-calificacion → CalificacionTest
+ ├─ /lista-postulantes → ListPostulantes
+ ├─ /noticias → NoticiasAdmin (CRUD)
+ └─ /comunicados → ComunicadosAdmin (CRUD)
+
+/superadmin/dashboard → Dashboard superadmin
+/unauthorized → 403
+/:pathMatch(.*) → 404
+```
+
+**Guards:**
+- `requiresAuth` — verifica token en localStorage, redirige a login
+- `guest` — evita acceso a login si ya autenticado
+- `role` — valida rol, redirige a `/403` si no aplica
+
+### 6.2 Stores Pinia (14 stores)
+
+| Store | API | Propósito principal |
+|-------|-----|---------------------|
+| `user.js` | Options | Auth admin, roles, persistencia localStorage |
+| `postulanteStore.js` | Composition | Auth postulante, Device-Id |
+| `examen.store.js` | Options | Flujo completo de examen online |
+| `procesosAdmisionStore.js` | Options | CRUD procesos admisión (admin) |
+| `area.store.js` | Options | CRUD áreas |
+| `curso.store.js` | Options | CRUD cursos |
+| `pregunta.store.js` | Options | CRUD preguntas |
+| `proceso.store.js` | Options | CRUD procesos examen |
+| `reglaAreaProceso.store.js` | Options | Reglas de asignación de preguntas |
+| `noticiasStore.js` | Options | CRUD noticias (admin) |
+| `noticiasPublicas.store.js` | Options | Noticias para web pública |
+| `procesoAdmisionResultado.store.js` | Options | Upload/fetch archivos de resultados |
+| `comunicadosStore.js` | Options | CRUD comunicados |
+| `web.js` | Options | Estado web pública (procesos, comunicado activo) |
+
+### 6.3 Instancias Axios
+
+**`axios.js`** (Admin):
+- `baseURL`: `VITE_API_URL`
+- Request interceptor: inyecta `Bearer ${localStorage.token}`
+- Response interceptor: 401 → clearAuth + redirect login; 403 → redirect /unauthorized
+
+**`axiosPostulante.js`** (Postulante):
+- Token en `postulante_token`
+- Header `Device-Id` opcional
+
+### 6.4 Componentes Principales
+
+| Componente | Líneas aprox. | Función |
+|-----------|--------------|---------|
+| `ConvocatoriasSection.vue` | ~1,457 | Tarjetas de procesos, modal detalles, archivos multi-sede |
+| `PreguntasExamen.vue` | ~598 | Examen online con timer, 1 pregunta/vez, Markdown+LaTeX |
+| `ProcessSection.vue` | ~365 | Timeline visual del cronograma |
+| `ComunicadoModal.vue` | ~207 | Modal carrusel de imágenes, auto-cierre por fecha |
+| `HeroSection.vue` | — | Banner con card dinámica (v-if hayResultados) |
+| `ProcesoResultado.vue` | — | Sección pública por proceso con archivos v-for |
+| `ProcesosAdmisionList.vue` | — | Tabla admin con modales inline para CRUD + Resultados |
+
+---
+
+## 7. FLUJOS FUNCIONALES
+
+### 7.1 Gestión de Procesos de Admisión
+
+```
+Admin crea ProcesoAdmision
+ ├─ título, descripción, slug único
+ ├─ Fechas: pre-inscripción → inscripción → examen → resultados
+ ├─ Imágenes: imagen (JPG/PNG), banner, brochure PDF
+ ├─ Links externos: pre-inscripción, inscripción, resultados, reglamento
+ ├─ Estados: nuevo → publicado → en_proceso → finalizado → cancelado
+ └─ ProcesoAdmisionDetalle: requisitos, cronograma, etc.
+
+Postulante visualiza en página pública
+ └─ GET /api/procesos-admision (solo publicados, latest first)
+ └─ ConvocatoriasSection: tarjetas + modal detalles
+
+Admin gestiona resultados
+ └─ ProcesoAdmisionResultadoArchivo por sede/slot
+ ├─ Upload PDF por slot (1-6) por proceso
+ └─ Descarga pública: GET /api/proceso-resultado/{id}/archivos
+```
+
+### 7.2 Examen Online (Flujo Completo)
+
+```
+1. Login postulante → Dashboard (procesos disponibles donde no ha rendido)
+2. GET /api/examen/procesos → seleccionar proceso
+3. GET /api/examen/areas?proceso_id={id} → seleccionar área
+4. POST /api/examen/crear { area_proceso_id } → crea Examen (estado: pendiente)
+5. POST /api/examen/{id}/generar-preguntas → selección aleatoria por ReglaAreaProceso
+6. GET /api/examen/{id}/preguntas → lista sin respuesta_correcta visible
+7. POST /api/examen/iniciar → hora_inicio = NOW, estado = en_curso
+8. POST /api/examen/pregunta/{id}/responder { respuesta } → valida + puntúa (Calificacion)
+9. POST /api/examen/{id}/finalizar → estado = finalizado, suma puntaje
+10. POST /api/examen/{id}/calificar → computa estadísticas:
+ ├─ total_correctas / incorrectas / nulas
+ ├─ total_puntos, porcentaje_correctas
+ ├─ calificacion_sobre_20
+ └─ correctas_por_curso (breakdown)
+11. Resultados.vue: gráficos Chart.js por curso + ranking
+```
+
+**Restricciones:**
+- Máximo `intentos_maximos` (campo de Proceso)
+- Requiere `Pago` si `proceso.requiere_pago = true`
+- Timer bloquea respuestas al expirar `duracion_minutos`
+
+### 7.3 Gestión de Archivos de Resultados (Multi-sede)
+
+```
+Modelo: ProcesoAdmisionResultadoArchivo
+Campos: proceso_admision_id, nombre, file_path, orden (UNIQUE por proceso)
+
+Admin:
+ ├─ Modal "Resultados" en ProcesosAdmisionList (6 slots pre-nombrados)
+ ├─ Slots nombrados por: generarNombreSlot(orden, proceso) en store
+ │ slot 1 → "Resultados Sábado {fecha_examen1}"
+ │ slot 2 → "Resultados CONADIS"
+ │ slots 3-6 → slugs personalizados
+ └─ customRequest de Ant Design: requiere onSuccess()/onError()
+
+Postulante:
+ ├─ GET /api/proceso-resultado/{id}/archivos (público)
+ ├─ archivosPorProceso en web.js store → mapa por procesoId
+ ├─ fetchArchivosMultiples con Promise.allSettled (multi-sede)
+ └─ HeroSection: card con v-if="hayResultados" (≥1 archivo)
+```
+
+### 7.4 Gestión de Contenido (Noticias + Comunicados)
+
+**Noticias:**
+- CRUD admin con editor WYSIWYG (vue-quill)
+- Campo `slug` único, `contenido` markdown
+- Filtros: categoría, tag_color, destacado, publicado, orden
+- Web pública: GET /api/noticias, GET /api/noticias/{slug}
+
+**Comunicados:**
+- Múltiples imágenes carrusel (ComunicadoImagen)
+- Solo 1 activo a la vez: GET /api/comunicados/activo
+- Fecha_inicio / fecha_fin de vigencia
+- ComunicadoModal.vue: auto-cierre, botón de acción
+
+---
+
+## 8. INFRAESTRUCTURA Y DESPLIEGUE
+
+### 8.1 Docker Compose (Producción)
+
+| Servicio | Imagen | Puerto | RAM |
+|----------|--------|--------|-----|
+| **nginx** | nginx:alpine | 127.0.0.1:8080 | 64MB |
+| **backend** | ghcr.io/…/backend:latest | 9000 (interno) | 1GB |
+| **frontend** | ghcr.io/…/frontend:latest | interno | 64MB |
+| **mysql** | mysql:8.0 | 127.0.0.1:3306 | 512MB |
+
+**Volúmenes:** `mysql_data` (BD), `backend_storage` (archivos subidos)
+**Red:** Bridge `admision_net`
+**Health checks:** PHP ping (backend), mysqladmin ping (mysql)
+
+### 8.2 Dockerfile Backend (Multi-Stage)
+
+```dockerfile
+# Stage 1: Composer (sin dev, optimizado)
+FROM composer:2 AS vendor
+RUN composer install --no-dev --optimize-autoloader --ignore-platform-reqs
+
+# Stage 2: PHP 8.4-fpm-alpine
+FROM php:8.4-fpm-alpine
+# Extensiones: PDO MySQL, BCMath, MBString, GD, cURL, ZIP, XML, Intl
+# OPcache: 128MB, 10k archivos, validate_timestamps=0
+# Upload: 20MB archivos, 25MB POST
+# PHP-FPM: 15 max_children, soporte 100+ concurrentes
+# Entrypoint: config:cache + route:cache → php-fpm
+```
+
+### 8.3 Dockerfile Frontend
+
+```dockerfile
+FROM node:20-alpine AS build
+RUN npm ci && npm run build
+
+FROM nginx:alpine
+COPY --from=build /app/dist /usr/share/nginx/html
+```
+
+### 8.4 Nginx
+
+```nginx
+# Frontend SPA
+location / { try_files $uri $uri/ /index.html; }
+
+# API (proxy a PHP-FPM backend)
+location /api { proxy_pass http://backend:9000; }
+
+# Storage público
+location /storage { alias /var/www/html/storage/app/public; }
+```
+
+---
+
+## 9. PATRONES DE DESARROLLO
+
+### 9.1 Convenciones Backend
+
+- Validación en controladores (`Validator::make`), no Form Requests
+- Respuestas JSON estándar: `{ success, data/message, errors }`
+- HTTP status codes semánticos (200, 201, 400, 401, 403, 422, 500)
+- Accessors para URLs de storage: `getImagenUrlAttribute()`
+- `$casts` en modelos para tipos (boolean, datetime, integer, array)
+- `ON DELETE CASCADE` en FK críticas
+- `->latest()` para orden cronológico inverso
+
+### 9.2 Convenciones Frontend
+
+- Componentes: PascalCase | Métodos/props: camelCase
+- Stores Pinia Options API (mayoría) — patrón: fetch → state → computed
+- Modales admin: inline en el mismo archivo `.vue` (patrón establecido)
+- `axios` (admin) vs `axiosPostulante` (público/postulante) — instancias separadas
+- Páginas públicas completas en `WebPageSections/navbarcontent/`
+- Datos sensibles de auth solo en localStorage (no Pinia, no sessionStorage)
+
+### 9.3 Patrones de Componentes
+
+```vue
+// Patrón típico: Store + Component
+const store = useProcesoAdmisionStore()
+onMounted(() => store.fetchProcesos({ page: 1 }))
+
+// Patrón: Tabla paginada (AntD)
+
+
+// Patrón: Modal inline (no rutas separadas)
+
+```
+
+### 9.4 Seguridad
+
+- Bearer tokens con expiración (12h admin, 1h postulante)
+- Validación siempre en servidor (no confiar en cliente)
+- `BCRYPT_ROUNDS=12` para passwords
+- `revoke tokens` al logout (`$user->tokens()->delete()`)
+- Storage `public` disk — sin acceso a archivos privados vía HTTP
+- CORS configurado via `config/cors.php`
+
+---
+
+## 10. OBSERVACIONES: FORTALEZAS Y MEJORAS
+
+### Fortalezas
+
+1. **Arquitectura modular** — separación clara backend/frontend, cada uno con Dockerfile propio
+2. **Auth robusta** — Sanctum + Spatie, tokens con expiración, guards de router
+3. **State management ordenado** — 14 stores Pinia con responsabilidades claras
+4. **Multi-sede** — ProcesoAdmisionResultadoArchivo con orden y Promise.allSettled
+5. **Docker optimizado** — multi-stage builds, healthchecks, límites RAM
+6. **Rich content** — soporte Markdown + LaTeX en preguntas de examen
+7. **Patrón de modales inline** — consistente en todo el admin
+8. **Encoding correcto** — fix UTF-8 para tildes en archivos Windows-1252
+
+### Mejoras Sugeridas
+
+#### Backend
+
+| Área | Problema | Solución recomendada |
+|------|----------|---------------------|
+| Form Requests | Validación no reutilizable | Crear clases `FormRequest` |
+| Rate Limiting | Sin throttle | `ThrottleRequests` middleware |
+| Tests | Sin cobertura visible | PHPUnit para flujos críticos (examen, auth) |
+| N+1 Queries | Riesgo en relaciones | Usar `with()` en índices con relaciones |
+| Caché | `database` driver (lento) | Redis para sesiones y caché |
+| API Docs | Sin OpenAPI | `scribe-php` o `l5-swagger` |
+| Servicios | Lógica en controllers | `PagoService`, `ExamenService` |
+
+#### Frontend
+
+| Área | Problema | Solución recomendada |
+|------|----------|---------------------|
+| TypeScript | Sin tipos | Migración gradual a TS |
+| Tests | Sin cobertura | Vitest + Vue Test Utils |
+| Composables | Lógica en componentes grandes | Extraer composables reutilizables |
+| Lazy loading | Sin code splitting visible | Dynamic imports en rutas |
+| Accesibilidad | No auditado | WCAG 2.1 audit |
+| i18n | Solo español | `vue-i18n` si se requiere multiidioma |
+
+#### Infraestructura
+
+| Área | Problema | Solución recomendada |
+|------|----------|---------------------|
+| Backup | Sin automatización | Cron + mysqldump → S3/volumen |
+| Logs | Solo internos | Centralizar (ELK/CloudWatch) |
+| SSL | No visible | Let's Encrypt + Certbot |
+| Secrets | .env en repo | Secret manager o .env.local ignorado |
+| Monitoreo | Sin métricas | Prometheus + Grafana |
+
+---
+
+## 11. RESUMEN EJECUTIVO
+
+### Stack Definitivo
+- **Backend:** Laravel 12 + Sanctum + Spatie + MySQL 8.0
+- **Frontend:** Vue 3.5 + Vite 7 + Pinia + Ant Design Vue 4
+- **DevOps:** Docker Compose, Nginx, GitHub Actions
+- **Base de datos:** 30+ tablas con integridad referencial
+
+### Funcionalidades Implementadas
+1. Procesos de Admisión multi-sede con archivos de resultados por slot
+2. Exámenes online con timer, generación dinámica, calificación automática
+3. Portal Admin con CRUD completo (áreas, cursos, preguntas, procesos, noticias)
+4. Portal Postulante (dashboard, examen, resultados Chart.js, seguimiento admisión)
+5. Página Web Pública (convocatorias, noticias, comunicados, descarga resultados)
+
+### Métricas
+- 73 endpoints API REST
+- 21 modelos Eloquent
+- 14 stores Pinia
+- 23 rutas Vue Router
+- 30+ tablas en BD
+- Soporte 100+ usuarios concurrentes (PHP-FPM config)
+
+### Estado del Proyecto
+- **Etapa:** MVP con iteraciones activas (commits recientes)
+- **Calidad:** Código limpio y consistente, sin tests automatizados
+- **Seguridad:** Auth/Authz implementada, validación servidor
+- **Escalabilidad:** Containerizado, DB con FK integridad, caché configurable