Merge pull request #1 from elmer-20/feature/crud_admin_usuarios
feat: Implementacion de administracion de usuarios todo okmain
commit
5d2434df0b
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
},
|
||||
},
|
||||
})
|
||||
@ -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…
Reference in New Issue