Merge pull request #1 from elmer-20/feature/crud_admin_usuarios

feat: Implementacion de administracion de usuarios
todo ok
main
Anghelo Flores 1 week ago committed by GitHub
commit 5d2434df0b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

@ -0,0 +1,237 @@
<?php
namespace App\Http\Controllers\Administracion;
use App\Http\Controllers\Controller;
use App\Models\User;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Validator;
use Illuminate\Support\Facades\Log;
use Spatie\Permission\Models\Role;
class UserController extends Controller
{
public function index(Request $request)
{
try {
$query = User::with('roles');
if ($request->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);
}
}
}

@ -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']);
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']);
});

@ -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')
}
]

@ -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
},
},
})

@ -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]) {

@ -0,0 +1,540 @@
<template>
<div class="areas-container">
<!-- Header -->
<div class="areas-header">
<div class="header-title">
<h2>Usuarios del sistema</h2>
<p class="subtitle">Administra las cuentas de acceso al panel</p>
</div>
<a-button type="primary" @click="abrirModalCrear">
<template #icon><PlusOutlined /></template>
Nuevo usuario
</a-button>
</div>
<!-- Filtros -->
<div class="filtros-bar">
<a-input-search
v-model:value="filtros.buscar"
placeholder="Buscar por nombre o email..."
style="width: 280px"
allow-clear
@search="buscar"
@change="onBuscarChange"
/>
<a-select
v-model:value="filtros.rol"
placeholder="Filtrar por rol"
style="width: 180px"
allow-clear
@change="buscar"
>
<a-select-option v-for="rol in store.roles" :key="rol.name" :value="rol.name">
{{ rol.name }}
</a-select-option>
</a-select>
</div>
<!-- Tabla -->
<div class="areas-table-container">
<a-table
:columns="columnas"
:data-source="store.usuarios"
:loading="store.loading"
:pagination="{
current: store.pagination.current,
pageSize: store.pagination.pageSize,
total: store.pagination.total,
showTotal: (t) => `${t} usuarios`,
}"
row-key="id"
class="areas-table"
@change="onTableChange"
>
<template #bodyCell="{ column, record }">
<!-- Nombre + avatar inicial -->
<template v-if="column.key === 'name'">
<div style="display: flex; align-items: center; gap: 10px;">
<a-avatar :style="{ background: avatarColor(record.name), fontSize: '13px' }" :size="32">
{{ iniciales(record.name) }}
</a-avatar>
<span>{{ record.name }}</span>
</div>
</template>
<!-- Rol -->
<template v-else-if="column.key === 'roles'">
<a-tag
v-for="rol in record.roles"
:key="rol"
:color="rolColor(rol)"
>
{{ rol }}
</a-tag>
<a-tag v-if="!record.roles?.length" color="default">Sin rol</a-tag>
</template>
<!-- Fecha -->
<template v-else-if="column.key === 'created_at'">
<span style="font-size: 12px; color: #666;">{{ formatDate(record.created_at) }}</span>
</template>
<!-- Acciones -->
<template v-else-if="column.key === 'acciones'">
<div style="display: flex; gap: 6px; flex-wrap: wrap;">
<a-button size="small" @click="abrirModalEditar(record)">
<template #icon><EditOutlined /></template>
Editar
</a-button>
<a-button size="small" @click="abrirModalPassword(record)">
<template #icon><LockOutlined /></template>
Contraseña
</a-button>
<a-popconfirm
title="¿Eliminar este usuario?"
ok-text="Sí, eliminar"
cancel-text="Cancelar"
ok-type="danger"
:disabled="record.id === userStore.user?.id"
@confirm="eliminarUsuario(record.id)"
>
<a-button
size="small"
danger
:disabled="record.id === userStore.user?.id"
>
<template #icon><DeleteOutlined /></template>
Eliminar
</a-button>
</a-popconfirm>
</div>
</template>
</template>
</a-table>
</div>
<!-- ===== MODAL CREAR ===== -->
<a-modal
v-model:open="modalCrear.visible"
title="Nuevo usuario"
:confirm-loading="modalCrear.loading"
ok-text="Crear usuario"
cancel-text="Cancelar"
@ok="submitCrear"
@cancel="resetModalCrear"
>
<a-form
ref="formCrearRef"
:model="modalCrear.form"
:rules="reglasCrear"
layout="vertical"
style="margin-top: 12px;"
>
<a-form-item label="Nombre completo" name="name">
<a-input v-model:value="modalCrear.form.name" placeholder="Ej: Juan Pérez" />
</a-form-item>
<a-form-item label="Correo electrónico" name="email">
<a-input v-model:value="modalCrear.form.email" placeholder="correo@ejemplo.com" />
</a-form-item>
<a-form-item label="Rol" name="rol">
<a-select v-model:value="modalCrear.form.rol" placeholder="Selecciona un rol">
<a-select-option v-for="rol in store.roles" :key="rol.name" :value="rol.name">
{{ rol.name }}
</a-select-option>
</a-select>
</a-form-item>
<a-form-item label="Contraseña" name="password">
<a-input-password v-model:value="modalCrear.form.password" placeholder="Mínimo 8 caracteres" />
</a-form-item>
<a-form-item label="Confirmar contraseña" name="password_confirmation">
<a-input-password v-model:value="modalCrear.form.password_confirmation" placeholder="Repite la contraseña" />
</a-form-item>
</a-form>
</a-modal>
<!-- ===== MODAL EDITAR ===== -->
<a-modal
v-model:open="modalEditar.visible"
title="Editar usuario"
:confirm-loading="modalEditar.loading"
ok-text="Guardar cambios"
cancel-text="Cancelar"
@ok="submitEditar"
@cancel="resetModalEditar"
>
<a-form
ref="formEditarRef"
:model="modalEditar.form"
:rules="reglasEditar"
layout="vertical"
style="margin-top: 12px;"
>
<a-form-item label="Nombre completo" name="name">
<a-input v-model:value="modalEditar.form.name" />
</a-form-item>
<a-form-item label="Correo electrónico" name="email">
<a-input v-model:value="modalEditar.form.email" />
</a-form-item>
<a-form-item label="Rol" name="rol">
<a-select v-model:value="modalEditar.form.rol" placeholder="Selecciona un rol">
<a-select-option v-for="rol in store.roles" :key="rol.name" :value="rol.name">
{{ rol.name }}
</a-select-option>
</a-select>
</a-form-item>
</a-form>
</a-modal>
<!-- ===== MODAL CAMBIAR CONTRASEÑA ===== -->
<a-modal
v-model:open="modalPassword.visible"
title="Cambiar contraseña"
:confirm-loading="modalPassword.loading"
ok-text="Actualizar contraseña"
cancel-text="Cancelar"
@ok="submitPassword"
@cancel="resetModalPassword"
>
<a-alert
v-if="modalPassword.esPropia"
message="Estás cambiando tu propia contraseña. Se te pedirá la contraseña actual."
type="info"
show-icon
style="margin-bottom: 16px;"
/>
<a-alert
v-else
:message="`Cambiando contraseña de ${modalPassword.usuario?.name}. La sesión activa de este usuario será cerrada.`"
type="warning"
show-icon
style="margin-bottom: 16px;"
/>
<a-form
ref="formPasswordRef"
:model="modalPassword.form"
:rules="reglasPassword"
layout="vertical"
>
<a-form-item
v-if="modalPassword.esPropia"
label="Contraseña actual"
name="current_password"
>
<a-input-password v-model:value="modalPassword.form.current_password" placeholder="Tu contraseña actual" />
</a-form-item>
<a-form-item label="Nueva contraseña" name="password">
<a-input-password v-model:value="modalPassword.form.password" placeholder="Mínimo 8 caracteres" />
</a-form-item>
<a-form-item label="Confirmar nueva contraseña" name="password_confirmation">
<a-input-password v-model:value="modalPassword.form.password_confirmation" placeholder="Repite la nueva contraseña" />
</a-form-item>
</a-form>
</a-modal>
</div>
</template>
<script setup>
import { ref, reactive, onMounted, computed } from 'vue'
import { message } from 'ant-design-vue'
import {
PlusOutlined,
EditOutlined,
DeleteOutlined,
LockOutlined,
} from '@ant-design/icons-vue'
import { useUsuariosStore } from '../../../store/usuariosStore'
import { useUserStore } from '../../../store/user'
const store = useUsuariosStore()
const userStore = useUserStore()
// Tabla
const columnas = [
{ title: 'Nombre', key: 'name', dataIndex: 'name' },
{ title: 'Email', key: 'email', dataIndex: 'email' },
{ title: 'Rol', key: 'roles' },
{ title: 'Creado', key: 'created_at' },
{ title: 'Acciones', key: 'acciones', width: 240 },
]
const filtros = reactive({ buscar: '', rol: undefined })
const onTableChange = ({ current }) => {
store.fetchUsuarios({ page: current, buscar: filtros.buscar, rol: filtros.rol })
}
let buscarTimer = null
const onBuscarChange = () => {
clearTimeout(buscarTimer)
buscarTimer = setTimeout(() => buscar(), 400)
}
const buscar = () => {
store.fetchUsuarios({ page: 1, buscar: filtros.buscar, rol: filtros.rol })
}
// Helpers
const iniciales = (name) => {
if (!name) return 'U'
const parts = name.split(' ')
return parts.length === 1
? parts[0][0].toUpperCase()
: (parts[0][0] + parts[parts.length - 1][0]).toUpperCase()
}
const COLORS = ['#1890ff','#52c41a','#fa8c16','#f5222d','#722ed1','#13c2c2','#eb2f96','#faad14']
const avatarColor = (name) => {
if (!name) return COLORS[0]
const idx = name.split('').reduce((a, c) => a + c.charCodeAt(0), 0)
return COLORS[idx % COLORS.length]
}
const rolColor = (rol) => {
const map = { administrador: 'blue', superadmin: 'purple', editor: 'green', viewer: 'default' }
return map[rol?.toLowerCase()] || 'geekblue'
}
const formatDate = (val) => {
if (!val) return '—'
return new Date(val).toLocaleDateString('es-PE', { day: '2-digit', month: 'short', year: 'numeric' })
}
// Modal Crear
const formCrearRef = ref(null)
const modalCrear = reactive({
visible: false,
loading: false,
form: { name: '', email: '', rol: undefined, password: '', password_confirmation: '' },
})
const reglasCrear = {
name: [{ required: true, message: 'Ingresa el nombre' }],
email: [{ required: true, type: 'email', message: 'Ingresa un email válido' }],
rol: [{ required: true, message: 'Selecciona un rol' }],
password: [{ required: true, min: 8, message: 'Mínimo 8 caracteres' }],
password_confirmation: [{ required: true, message: 'Confirma la contraseña' }],
}
const abrirModalCrear = () => { modalCrear.visible = true }
const resetModalCrear = () => {
formCrearRef.value?.resetFields()
Object.assign(modalCrear.form, { name: '', email: '', rol: undefined, password: '', password_confirmation: '' })
}
const submitCrear = async () => {
try {
await formCrearRef.value.validate()
} catch {
return
}
modalCrear.loading = true
try {
await store.createUsuario({ ...modalCrear.form })
message.success('Usuario creado correctamente')
modalCrear.visible = false
resetModalCrear()
store.fetchUsuarios({ page: 1 })
} catch (error) {
mostrarErrores(error)
} finally {
modalCrear.loading = false
}
}
// Modal Editar
const formEditarRef = ref(null)
const modalEditar = reactive({
visible: false,
loading: false,
id: null,
form: { name: '', email: '', rol: undefined },
})
const reglasEditar = {
name: [{ required: true, message: 'Ingresa el nombre' }],
email: [{ required: true, type: 'email', message: 'Ingresa un email válido' }],
rol: [{ required: true, message: 'Selecciona un rol' }],
}
const abrirModalEditar = (record) => {
modalEditar.id = record.id
modalEditar.form.name = record.name
modalEditar.form.email = record.email
modalEditar.form.rol = record.roles?.[0] ?? undefined
modalEditar.visible = true
}
const resetModalEditar = () => {
formEditarRef.value?.resetFields()
modalEditar.id = null
Object.assign(modalEditar.form, { name: '', email: '', rol: undefined })
}
const submitEditar = async () => {
try {
await formEditarRef.value.validate()
} catch {
return
}
modalEditar.loading = true
try {
await store.updateUsuario(modalEditar.id, { ...modalEditar.form })
message.success('Usuario actualizado correctamente')
modalEditar.visible = false
resetModalEditar()
store.fetchUsuarios({ page: store.pagination.current, buscar: filtros.buscar, rol: filtros.rol })
} catch (error) {
mostrarErrores(error)
} finally {
modalEditar.loading = false
}
}
// Modal Contraseña
const formPasswordRef = ref(null)
const modalPassword = reactive({
visible: false,
loading: false,
usuario: null,
esPropia: false,
form: { current_password: '', password: '', password_confirmation: '' },
})
const reglasPassword = computed(() => ({
...(modalPassword.esPropia
? { current_password: [{ required: true, message: 'Ingresa tu contraseña actual' }] }
: {}),
password: [{ required: true, min: 8, message: 'Mínimo 8 caracteres' }],
password_confirmation: [{ required: true, message: 'Confirma la nueva contraseña' }],
}))
const abrirModalPassword = (record) => {
modalPassword.usuario = record
modalPassword.esPropia = record.id === userStore.user?.id
modalPassword.visible = true
}
const resetModalPassword = () => {
formPasswordRef.value?.resetFields()
Object.assign(modalPassword.form, { current_password: '', password: '', password_confirmation: '' })
modalPassword.usuario = null
}
const submitPassword = async () => {
try {
await formPasswordRef.value.validate()
} catch {
return
}
if (modalPassword.form.password !== modalPassword.form.password_confirmation) {
message.error('Las contraseñas no coinciden')
return
}
modalPassword.loading = true
try {
await store.changePassword(modalPassword.usuario.id, { ...modalPassword.form })
message.success('Contraseña actualizada correctamente')
modalPassword.visible = false
resetModalPassword()
} catch (error) {
mostrarErrores(error)
} finally {
modalPassword.loading = false
}
}
// Eliminar
const eliminarUsuario = async (id) => {
try {
await store.deleteUsuario(id)
message.success('Usuario eliminado')
store.fetchUsuarios({ page: store.pagination.current, buscar: filtros.buscar, rol: filtros.rol })
} catch (error) {
mostrarErrores(error)
}
}
// Errores del backend
const mostrarErrores = (error) => {
const errors = error?.response?.data?.errors
if (errors) {
Object.values(errors).flat().forEach((msg) => message.error(msg))
} else {
const msg = error?.response?.data?.message || 'Ocurrió un error inesperado'
message.error(msg)
}
}
// Init
onMounted(async () => {
await Promise.all([
store.fetchUsuarios({ page: 1 }),
store.fetchRoles(),
])
})
</script>
<style scoped>
.areas-container {
padding: 0;
}
.areas-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 20px;
flex-wrap: wrap;
gap: 12px;
}
.header-title h2 {
margin: 0 0 4px 0;
font-size: 20px;
font-weight: 600;
color: #1a1a2e;
}
.subtitle {
margin: 0;
color: #666;
font-size: 13px;
}
.filtros-bar {
display: flex;
gap: 12px;
margin-bottom: 16px;
flex-wrap: wrap;
}
.areas-table-container {
background: #fff;
border-radius: 8px;
overflow: hidden;
}
.areas-table :deep(.ant-table-thead th) {
background: #fafafa;
font-weight: 600;
font-size: 13px;
}
</style>

@ -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)
<a-table :dataSource="store.procesos" :pagination="pagination" :loading="store.loading" />
// Patrón: Modal inline (no rutas separadas)
<a-modal v-model:open="showModal" @ok="handleSubmit" />
```
### 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
Loading…
Cancel
Save