last_commits
parent
e16b365df7
commit
53dbbe5382
@ -0,0 +1,322 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Administracion;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\Request;
|
||||
use App\Models\AreaProceso;
|
||||
use App\Models\Proceso;
|
||||
use App\Models\ReglaAreaProceso;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class ReglaAreaProcesoController extends Controller
|
||||
{
|
||||
/**
|
||||
* Mostrar cursos de un area_proceso con reglas existentes
|
||||
*/
|
||||
|
||||
|
||||
|
||||
public function areasProcesos()
|
||||
{
|
||||
$areasProcesos = DB::table('area_proceso as ap')
|
||||
->leftJoin('reglas_area_proceso as r', 'ap.id', '=', 'r.area_proceso_id')
|
||||
->leftJoin('area_curso as ac', 'ap.area_id', '=', 'ac.area_id') // pivot area_curso
|
||||
->leftJoin('cursos as c', 'ac.curso_id', '=', 'c.id') // unimos los cursos reales
|
||||
->join('areas as a', 'ap.area_id', '=', 'a.id')
|
||||
->join('procesos as p', 'ap.proceso_id', '=', 'p.id')
|
||||
->select(
|
||||
'ap.id',
|
||||
'ap.area_id',
|
||||
'ap.proceso_id',
|
||||
'a.nombre as area_nombre',
|
||||
'p.nombre as proceso_nombre',
|
||||
DB::raw('COUNT(DISTINCT r.id) as reglas_count'),
|
||||
DB::raw('COUNT(DISTINCT c.id) as cursos_count')
|
||||
)
|
||||
->groupBy('ap.id', 'ap.area_id', 'ap.proceso_id', 'a.nombre', 'p.nombre')
|
||||
->get();
|
||||
|
||||
return response()->json([
|
||||
'areaProcesos' => $areasProcesos
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
public function index($areaProcesoId)
|
||||
{
|
||||
// Obtener el area_proceso y su proceso
|
||||
$areaProceso = DB::table('area_proceso as ap')
|
||||
->join('areas as a', 'a.id', '=', 'ap.area_id')
|
||||
->join('procesos as p', 'p.id', '=', 'ap.proceso_id')
|
||||
->where('ap.id', $areaProcesoId)
|
||||
->select('ap.id as area_proceso_id', 'a.id as area_id', 'a.nombre as area_nombre',
|
||||
'p.id as proceso_id', 'p.nombre as proceso_nombre', 'p.duracion as cantidad_total_preguntas')
|
||||
->first();
|
||||
|
||||
if (!$areaProceso) {
|
||||
return response()->json(['error' => 'AreaProceso no encontrado'], 404);
|
||||
}
|
||||
|
||||
// Traer todos los cursos del área (pivot area_curso)
|
||||
$cursos = DB::table('area_curso as ac')
|
||||
->join('cursos as c', 'c.id', '=', 'ac.curso_id')
|
||||
->where('ac.area_id', $areaProceso->area_id)
|
||||
->select('c.id as curso_id', 'c.nombre as nombre')
|
||||
->get();
|
||||
|
||||
// Traer reglas existentes para este area_proceso
|
||||
$reglasExistentes = DB::table('reglas_area_proceso')
|
||||
->where('area_proceso_id', $areaProcesoId)
|
||||
->get();
|
||||
|
||||
// Mapear cursos con reglas si existen
|
||||
$reglas = $cursos->map(function ($curso) use ($reglasExistentes) {
|
||||
$regla = $reglasExistentes->firstWhere('curso_id', $curso->curso_id);
|
||||
return [
|
||||
'curso_id' => $curso->curso_id,
|
||||
'nombre' => $curso->nombre,
|
||||
'regla_id' => $regla->id ?? null,
|
||||
'orden' => $regla->orden ?? null,
|
||||
'cantidad_preguntas' => $regla->cantidad_preguntas ?? null,
|
||||
'nivel_dificultad' => $regla->nivel_dificultad ?? 'medio',
|
||||
'ponderacion' => $regla->ponderacion ?? null,
|
||||
];
|
||||
})->sortBy('orden')->values();
|
||||
|
||||
return response()->json([
|
||||
'area_proceso_id' => $areaProceso->area_proceso_id,
|
||||
'proceso' => [
|
||||
'id' => $areaProceso->proceso_id,
|
||||
'nombre' => $areaProceso->proceso_nombre,
|
||||
'cantidad_total_preguntas' => $areaProceso->cantidad_total_preguntas,
|
||||
],
|
||||
'cursos' => $reglas,
|
||||
'total_preguntas_asignadas' => $reglasExistentes->sum('cantidad_preguntas'),
|
||||
]);
|
||||
}
|
||||
|
||||
public function store(Request $request, $areaProcesoId)
|
||||
{
|
||||
$request->validate([
|
||||
'curso_id' => 'required|exists:cursos,id',
|
||||
'cantidad_preguntas' => 'required|integer|min:0',
|
||||
'orden' => 'required|integer|min:1',
|
||||
'nivel_dificultad' => 'nullable|string|in:bajo,medio,alto',
|
||||
'ponderacion' => 'nullable|numeric|min:0|max:100',
|
||||
]);
|
||||
|
||||
// 🔹 Cantidad total de preguntas del proceso (vía pivot)
|
||||
$areaProceso = DB::table('area_proceso as ap')
|
||||
->join('procesos as p', 'ap.proceso_id', '=', 'p.id')
|
||||
->where('ap.id', $areaProcesoId)
|
||||
->select('p.cantidad_pregunta')
|
||||
->first();
|
||||
|
||||
if (!$areaProceso) {
|
||||
return response()->json([
|
||||
'error' => 'No se encontró el proceso asociado al área'
|
||||
], 404);
|
||||
}
|
||||
|
||||
$totalPreguntasProceso = $areaProceso->cantidad_pregunta;
|
||||
|
||||
// 🔹 Total ya asignado (excluyendo este curso si ya existe)
|
||||
$totalAsignado = DB::table('reglas_area_proceso')
|
||||
->where('area_proceso_id', $areaProcesoId)
|
||||
->where('curso_id', '!=', $request->curso_id)
|
||||
->sum('cantidad_preguntas');
|
||||
|
||||
$totalNuevo = $totalAsignado + $request->cantidad_preguntas;
|
||||
|
||||
if ($totalNuevo > $totalPreguntasProceso) {
|
||||
return response()->json([
|
||||
'error' => 'Excede la cantidad total de preguntas del proceso. Disponible: ' .
|
||||
($totalPreguntasProceso - $totalAsignado)
|
||||
], 422);
|
||||
}
|
||||
|
||||
// 🔹 Insertar o actualizar UNA regla
|
||||
DB::table('reglas_area_proceso')->updateOrInsert(
|
||||
[
|
||||
'area_proceso_id' => $areaProcesoId,
|
||||
'curso_id' => $request->curso_id,
|
||||
],
|
||||
[
|
||||
'cantidad_preguntas' => $request->cantidad_preguntas,
|
||||
'orden' => $request->orden,
|
||||
'nivel_dificultad' => $request->nivel_dificultad ?? 'medio',
|
||||
'ponderacion' => $request->ponderacion ?? 0,
|
||||
'updated_at' => now(),
|
||||
'created_at' => now(),
|
||||
]
|
||||
);
|
||||
|
||||
$totalAsignadoFinal = DB::table('reglas_area_proceso')
|
||||
->where('area_proceso_id', $areaProcesoId)
|
||||
->sum('cantidad_preguntas');
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => 'Regla guardada correctamente',
|
||||
'total_preguntas_asignadas' => $totalAsignadoFinal,
|
||||
'preguntas_disponibles' => $totalPreguntasProceso - $totalAsignadoFinal,
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
public function update(Request $request, $reglaId)
|
||||
{
|
||||
$request->validate([
|
||||
'cantidad_preguntas' => 'required|integer|min:0',
|
||||
'orden' => 'required|integer|min:1',
|
||||
'nivel_dificultad' => 'nullable|string|in:bajo,medio,alto',
|
||||
'ponderacion' => 'nullable|numeric|min:0|max:100',
|
||||
]);
|
||||
|
||||
// 🔹 Obtener la regla actual
|
||||
$regla = DB::table('reglas_area_proceso')
|
||||
->where('id', $reglaId)
|
||||
->first();
|
||||
|
||||
if (!$regla) {
|
||||
return response()->json([
|
||||
'error' => 'La regla no existe'
|
||||
], 404);
|
||||
}
|
||||
|
||||
// 🔹 Obtener cantidad total de preguntas del proceso (vía pivot)
|
||||
$areaProceso = DB::table('area_proceso as ap')
|
||||
->join('procesos as p', 'ap.proceso_id', '=', 'p.id')
|
||||
->where('ap.id', $regla->area_proceso_id)
|
||||
->select('p.cantidad_pregunta')
|
||||
->first();
|
||||
|
||||
if (!$areaProceso) {
|
||||
return response()->json([
|
||||
'error' => 'No se encontró el proceso asociado al área'
|
||||
], 404);
|
||||
}
|
||||
|
||||
$totalPreguntasProceso = $areaProceso->cantidad_pregunta;
|
||||
|
||||
// 🔹 Total asignado EXCLUYENDO esta regla
|
||||
$totalAsignado = DB::table('reglas_area_proceso')
|
||||
->where('area_proceso_id', $regla->area_proceso_id)
|
||||
->where('id', '!=', $reglaId)
|
||||
->sum('cantidad_preguntas');
|
||||
|
||||
$totalNuevo = $totalAsignado + $request->cantidad_preguntas;
|
||||
|
||||
if ($totalNuevo > $totalPreguntasProceso) {
|
||||
return response()->json([
|
||||
'error' => 'Excede la cantidad total de preguntas del proceso. Disponible: ' .
|
||||
($totalPreguntasProceso - $totalAsignado)
|
||||
], 422);
|
||||
}
|
||||
|
||||
// 🔹 Actualizar la regla
|
||||
DB::table('reglas_area_proceso')
|
||||
->where('id', $reglaId)
|
||||
->update([
|
||||
'cantidad_preguntas' => $request->cantidad_preguntas,
|
||||
'orden' => $request->orden,
|
||||
'nivel_dificultad' => $request->nivel_dificultad ?? 'medio',
|
||||
'ponderacion' => $request->ponderacion ?? 0,
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
|
||||
$totalAsignadoFinal = DB::table('reglas_area_proceso')
|
||||
->where('area_proceso_id', $regla->area_proceso_id)
|
||||
->sum('cantidad_preguntas');
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => 'Regla actualizada correctamente',
|
||||
'total_preguntas_asignadas' => $totalAsignadoFinal,
|
||||
'preguntas_disponibles' => $totalPreguntasProceso - $totalAsignadoFinal,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Eliminar una regla
|
||||
*/
|
||||
public function destroy($reglaId)
|
||||
{
|
||||
$regla = ReglaAreaProceso::findOrFail($reglaId);
|
||||
$areaProcesoId = $regla->area_proceso_id;
|
||||
|
||||
$regla->delete();
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => 'Regla eliminada correctamente',
|
||||
'total_preguntas_asignadas' => ReglaAreaProceso::where('area_proceso_id', $areaProcesoId)
|
||||
->sum('cantidad_preguntas'),
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
|
||||
public function storeMultiple(Request $request, $areaProcesoId)
|
||||
{
|
||||
$request->validate([
|
||||
'reglas' => 'required|array',
|
||||
'reglas.*.curso_id' => 'required|exists:cursos,id',
|
||||
'reglas.*.cantidad_preguntas' => 'required|integer|min:0',
|
||||
'reglas.*.orden' => 'required|integer|min:1',
|
||||
'reglas.*.nivel_dificultad' => 'nullable|string|in:bajo,medio,alto',
|
||||
'reglas.*.ponderacion' => 'nullable|numeric|min:0|max:100',
|
||||
]);
|
||||
|
||||
// Obtener la cantidad total de preguntas del proceso a través del pivot
|
||||
$areaProceso = DB::table('area_proceso as ap')
|
||||
->join('procesos as p', 'ap.proceso_id', '=', 'p.id')
|
||||
->where('ap.id', $areaProcesoId)
|
||||
->select('p.cantidad_pregunta')
|
||||
->first();
|
||||
|
||||
$totalPreguntasProceso = $areaProceso->cantidad_pregunta ?? 0;
|
||||
|
||||
// Validar total de preguntas asignadas
|
||||
$totalNuevo = collect($request->reglas)->sum('cantidad_preguntas');
|
||||
if ($totalNuevo > $totalPreguntasProceso) {
|
||||
return response()->json([
|
||||
'error' => 'Excede la cantidad total de preguntas del proceso. Máximo permitido: ' . $totalPreguntasProceso
|
||||
], 422);
|
||||
}
|
||||
|
||||
// Eliminar reglas existentes directamente en la tabla pivot
|
||||
DB::table('reglas_area_proceso')->where('area_proceso_id', $areaProcesoId)->delete();
|
||||
|
||||
// Insertar las nuevas reglas
|
||||
$insertData = collect($request->reglas)->map(function ($r) use ($areaProcesoId) {
|
||||
return [
|
||||
'area_proceso_id' => $areaProcesoId,
|
||||
'curso_id' => $r['curso_id'],
|
||||
'cantidad_preguntas' => $r['cantidad_preguntas'],
|
||||
'orden' => $r['orden'],
|
||||
'nivel_dificultad' => $r['nivel_dificultad'] ?? 'medio',
|
||||
'ponderacion' => $r['ponderacion'] ?? 0,
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
];
|
||||
})->toArray();
|
||||
|
||||
if (!empty($insertData)) {
|
||||
DB::table('reglas_area_proceso')->insert($insertData);
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => 'Reglas guardadas correctamente',
|
||||
'total_preguntas_asignadas' => $totalNuevo,
|
||||
'preguntas_disponibles' => $totalPreguntasProceso - $totalNuevo,
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
@ -0,0 +1,500 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
use App\Models\Postulante;
|
||||
use App\Models\Proceso;
|
||||
use App\Models\Area;
|
||||
use App\Models\Curso;
|
||||
use App\Models\Pregunta;
|
||||
use App\Models\Alternativa;
|
||||
use App\Models\Examen;
|
||||
use App\Models\PreguntaAsignada;
|
||||
use App\Models\AlternativaAsignada;
|
||||
use App\Models\DetalleResultado;
|
||||
use App\Models\RespuestaPostulante;
|
||||
use App\Models\ResultadoExamen;
|
||||
use App\Models\Recomendaciones;
|
||||
use App\Models\Pago;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use App\Services\ExamenService;
|
||||
|
||||
class ExamenController extends Controller
|
||||
{
|
||||
|
||||
protected $examenService;
|
||||
|
||||
public function __construct(ExamenService $examenService)
|
||||
{
|
||||
$this->examenService = $examenService;
|
||||
}
|
||||
public function procesoexamen(Request $request)
|
||||
{
|
||||
$postulante = $request->user();
|
||||
|
||||
$procesos = Proceso::where('activo', 1)
|
||||
->whereNotExists(function ($q) use ($postulante) {
|
||||
$q->select(\DB::raw(1))
|
||||
->from('examenes')
|
||||
->join('area_proceso', 'area_proceso.id', '=', 'examenes.area_proceso_id')
|
||||
->whereColumn('area_proceso.proceso_id', 'procesos.id')
|
||||
->where('examenes.postulante_id', $postulante->id);
|
||||
})
|
||||
->select('id', 'nombre', 'requiere_pago')
|
||||
->get();
|
||||
|
||||
return response()->json($procesos);
|
||||
}
|
||||
|
||||
|
||||
public function areas(Request $request)
|
||||
{
|
||||
$procesoId = $request->query('proceso_id');
|
||||
|
||||
$areas = Area::select('areas.id', 'areas.nombre')
|
||||
->whereHas('procesos', function ($query) use ($procesoId) {
|
||||
$query->where('procesos.id', $procesoId)
|
||||
->where('procesos.activo', 1);
|
||||
})
|
||||
->with(['procesos' => function ($q) use ($procesoId) {
|
||||
$q->where('procesos.id', $procesoId)
|
||||
->select('procesos.id')
|
||||
->withPivot('id', 'proceso_id', 'area_id');
|
||||
}])
|
||||
->get()
|
||||
->map(function ($area) {
|
||||
$pivot = $area->procesos->first()->pivot;
|
||||
|
||||
return [
|
||||
'area_id' => $area->id,
|
||||
'nombre' => $area->nombre,
|
||||
'area_proceso_id' => $pivot->id, // 🔥 CLAVE
|
||||
];
|
||||
});
|
||||
|
||||
return response()->json($areas);
|
||||
}
|
||||
|
||||
|
||||
|
||||
public function crearExamen(Request $request)
|
||||
{
|
||||
$postulante = $request->user();
|
||||
|
||||
$request->validate([
|
||||
'area_proceso_id' => 'required|exists:area_proceso,id',
|
||||
]);
|
||||
|
||||
// 🔥 Obtener TODO desde el pivot
|
||||
$areaProceso = \DB::table('area_proceso')
|
||||
->join('procesos', 'procesos.id', '=', 'area_proceso.proceso_id')
|
||||
->join('areas', 'areas.id', '=', 'area_proceso.area_id')
|
||||
->where('area_proceso.id', $request->area_proceso_id)
|
||||
->select(
|
||||
'area_proceso.id',
|
||||
'area_proceso.area_id',
|
||||
'area_proceso.proceso_id',
|
||||
'procesos.requiere_pago'
|
||||
)
|
||||
->first();
|
||||
|
||||
if (!$areaProceso) {
|
||||
return response()->json(['message' => 'Relación área-proceso inválida'], 400);
|
||||
}
|
||||
|
||||
$yaDioProceso = Examen::join('area_proceso', 'area_proceso.id', '=', 'examenes.area_proceso_id')
|
||||
->where('examenes.postulante_id', $postulante->id)
|
||||
->where('area_proceso.proceso_id', $areaProceso->proceso_id)
|
||||
->exists();
|
||||
|
||||
if ($yaDioProceso) {
|
||||
return response()->json([
|
||||
'message' => 'Ya rendiste un examen para este proceso'
|
||||
], 400);
|
||||
}
|
||||
|
||||
$pagado = 0;
|
||||
$pagoId = null;
|
||||
|
||||
// 💰 Validación de pago
|
||||
if ($areaProceso->requiere_pago) {
|
||||
$request->validate([
|
||||
'tipo_pago' => 'required|in:pyto_peru,banco_nacion,caja',
|
||||
'codigo_pago' => 'required',
|
||||
]);
|
||||
|
||||
$response = $this->validarPago(
|
||||
$request->tipo_pago,
|
||||
$request->codigo_pago,
|
||||
$postulante->dni
|
||||
);
|
||||
|
||||
if (!$response['estado']) {
|
||||
return response()->json(['message' => 'Pago inválido'], 400);
|
||||
}
|
||||
|
||||
$pago = Pago::firstOrCreate(
|
||||
[
|
||||
'codigo_pago' => $request->codigo_pago,
|
||||
'tipo_pago' => $request->tipo_pago,
|
||||
],
|
||||
[
|
||||
'postulante_id' => $postulante->id,
|
||||
'monto' => $response['monto'],
|
||||
'fecha_pago' => $response['fecha_pago'],
|
||||
]
|
||||
);
|
||||
|
||||
if ($pago->utilizado) {
|
||||
return response()->json(['message' => 'Pago ya utilizado'], 400);
|
||||
}
|
||||
|
||||
$pago->update(['utilizado' => true]);
|
||||
|
||||
$pagado = 1;
|
||||
$pagoId = $pago->id;
|
||||
}
|
||||
|
||||
// 🧠 Crear / actualizar examen
|
||||
$examen = Examen::updateOrCreate(
|
||||
['postulante_id' => $postulante->id,
|
||||
'area_proceso_id' => $areaProceso->id, // 🔥 pivot
|
||||
'pagado' => $pagado,
|
||||
'tipo_pago' => $request->tipo_pago ?? null,
|
||||
'pago_id' => $pagoId,
|
||||
]
|
||||
);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'examen_id' => $examen->id,
|
||||
'mensaje' => 'Examen creado correctamente',
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
public function validarPago($tipoPago, $codigoPago, $dni)
|
||||
{
|
||||
return match ($tipoPago) {
|
||||
'pyto_peru' => $this->validarPagoPytoPeru($codigoPago, $dni),
|
||||
'banco_nacion' => $this->validarPagoBancoNacion($codigoPago, $dni),
|
||||
'caja' => $this->validarPagoCaja($codigoPago, $dni),
|
||||
default => ['estado' => false],
|
||||
};
|
||||
}
|
||||
|
||||
private function validarPagoBancoNacion($secuencia, $dni)
|
||||
{
|
||||
$url = 'https://inscripciones.admision.unap.edu.pe/api/get-pagos-banco-secuencia';
|
||||
|
||||
$response = Http::post($url, [
|
||||
'secuencia' => $secuencia,
|
||||
]);
|
||||
|
||||
if (!$response->successful()) {
|
||||
\Log::error('Error en la solicitud a la API', ['status' => $response->status(), 'body' => $response->body()]);
|
||||
return ['estado' => false, 'message' => 'Error en la solicitud a la API'];
|
||||
}
|
||||
|
||||
$data = $response->json();
|
||||
|
||||
if (isset($data['estado']) && $data['estado'] === true) {
|
||||
foreach ($data['datos'] as $pago) {
|
||||
if (isset($pago['dni']) && trim($pago['dni']) == trim($dni)) {
|
||||
return [
|
||||
'estado' => true,
|
||||
'monto' => $pago['imp_pag'],
|
||||
'fecha_pago' => $pago['fch_pag'],
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return ['estado' => false, 'message' => 'Datos no válidos o no encontrados'];
|
||||
}
|
||||
|
||||
private function validarPagoPytoPeru($authorizationCode, $dni)
|
||||
{
|
||||
$url = "https://service2.unap.edu.pe/PAYMENTS_MNG/v1/{$dni}/8/";
|
||||
$response = Http::get($url);
|
||||
|
||||
if ($response->successful()) {
|
||||
$data = $response->json();
|
||||
|
||||
|
||||
if (isset($data['data'][0]['autorizationCode']) && $data['data'][0]['autorizationCode'] === $authorizationCode) {
|
||||
return [
|
||||
'estado' => true,
|
||||
'monto' => $data['data'][0]['total'],
|
||||
'fecha_pago' => $data['data'][0]['confirmedDate'],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
return ['estado' => false, 'message' => 'El código de autorización no coincide.'];
|
||||
}
|
||||
|
||||
private function validarPagoCaja($codigoPago, $dni)
|
||||
{
|
||||
$url = "https://inscripciones.admision.unap.edu.pe/api/get-pago-caja/{$dni}/{$codigoPago}";
|
||||
|
||||
$response = Http::get($url);
|
||||
|
||||
if (!$response->successful()) {
|
||||
\Log::error('Error API Caja', [
|
||||
'status' => $response->status(),
|
||||
'body' => $response->body()
|
||||
]);
|
||||
return ['estado' => false, 'message' => 'Error en la API de Caja'];
|
||||
}
|
||||
|
||||
$data = $response->json();
|
||||
|
||||
if (
|
||||
isset($data['paymentTitle'], $data['paymentAmount'], $data['paymentDatetime']) &&
|
||||
trim($data['paymentTitle']) === trim($codigoPago)
|
||||
) {
|
||||
return [
|
||||
'estado' => true,
|
||||
'monto' => (float) $data['paymentAmount'],
|
||||
'fecha_pago' => $data['paymentDatetime'],
|
||||
];
|
||||
}
|
||||
|
||||
return ['estado' => false, 'message' => 'Pago no válido o no encontrado'];
|
||||
}
|
||||
|
||||
public function miExamenActual(Request $request)
|
||||
{
|
||||
$postulante = $request->user();
|
||||
|
||||
// Obtenemos el examen más reciente junto con área y proceso usando joins
|
||||
$examen = Examen::join('area_proceso', 'area_proceso.id', '=', 'examenes.area_proceso_id')
|
||||
->join('areas', 'areas.id', '=', 'area_proceso.area_id')
|
||||
->join('procesos', 'procesos.id', '=', 'area_proceso.proceso_id')
|
||||
->where('examenes.postulante_id', $postulante->id)
|
||||
->select(
|
||||
'examenes.id',
|
||||
'examenes.pagado',
|
||||
'examenes.tipo_pago',
|
||||
'examenes.intentos', // intentos del examen
|
||||
'areas.id as area_id',
|
||||
'areas.nombre as area_nombre',
|
||||
'procesos.id as proceso_id',
|
||||
'procesos.nombre as proceso_nombre',
|
||||
'procesos.intentos_maximos as proceso_intentos_maximos' // intentos máximos del proceso
|
||||
)
|
||||
->latest('examenes.created_at') // solo el más reciente
|
||||
->first();
|
||||
|
||||
if (!$examen) {
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'mensaje' => 'No tienes exámenes asignados actualmente',
|
||||
'examen' => null
|
||||
]);
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'examen' => [
|
||||
'id' => $examen->id,
|
||||
'intentos' => $examen->intentos,
|
||||
'intentos_maximos' => $examen->proceso_intentos_maximos,
|
||||
'proceso' => [
|
||||
'id' => $examen->proceso_id,
|
||||
'nombre' => $examen->proceso_nombre,
|
||||
],
|
||||
'area' => [
|
||||
'id' => $examen->area_id,
|
||||
'nombre' => $examen->area_nombre,
|
||||
],
|
||||
'pagado' => $examen->pagado,
|
||||
'tipo_pago' => $examen->tipo_pago ?? null,
|
||||
]
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* 2. GENERAR PREGUNTAS PARA EXAMEN (si no las tiene)
|
||||
*/
|
||||
public function generarPreguntas($examenId)
|
||||
{
|
||||
$examen = Examen::findOrFail($examenId);
|
||||
|
||||
// Verificar que el examen pertenece al usuario autenticado
|
||||
$postulante = request()->user();
|
||||
if ($examen->postulante_id !== $postulante->id) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => 'No autorizado'
|
||||
], 403);
|
||||
}
|
||||
|
||||
// Si YA tiene preguntas, no generar nuevas, solo confirmar éxito
|
||||
if ($examen->preguntasAsignadas()->exists()) {
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => 'El examen ya tiene preguntas generadas',
|
||||
'ya_tiene_preguntas' => true,
|
||||
'total_preguntas' => $examen->preguntasAsignadas()->count()
|
||||
]);
|
||||
}
|
||||
|
||||
// Si NO tiene preguntas, generar usando el servicio
|
||||
$resultado = $this->examenService->generarPreguntasExamen($examen);
|
||||
|
||||
if (!$resultado['success']) {
|
||||
return response()->json($resultado, 400);
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => 'Preguntas generadas exitosamente',
|
||||
'ya_tiene_preguntas' => false,
|
||||
'total_preguntas' => $examen->preguntasAsignadas()->count()
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 4. INICIAR EXAMEN (marcar hora inicio)
|
||||
*/
|
||||
public function iniciarExamen(Request $request)
|
||||
{
|
||||
$request->validate([
|
||||
'examen_id' => 'required|exists:examenes,id'
|
||||
]);
|
||||
|
||||
$examen = Examen::findOrFail($request->examen_id);
|
||||
|
||||
// Verificar que el examen pertenece al usuario autenticado
|
||||
$postulante = $request->user();
|
||||
if ($examen->postulante_id !== $postulante->id) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => 'No autorizado'
|
||||
], 403);
|
||||
}
|
||||
|
||||
// Traer datos del área-proceso directamente desde la DB
|
||||
$areaProceso = \DB::table('area_proceso')
|
||||
->join('procesos', 'area_proceso.proceso_id', '=', 'procesos.id')
|
||||
->join('areas', 'area_proceso.area_id', '=', 'areas.id')
|
||||
->where('area_proceso.id', $examen->area_proceso_id)
|
||||
->select(
|
||||
'procesos.id as proceso_id',
|
||||
'procesos.nombre as proceso_nombre',
|
||||
'procesos.duracion as proceso_duracion',
|
||||
'procesos.intentos_maximos as proceso_intentos_maximos',
|
||||
'areas.nombre as area_nombre'
|
||||
)
|
||||
->first();
|
||||
|
||||
// Verificar que tenga preguntas
|
||||
if (!$examen->preguntasAsignadas()->exists()) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => 'El examen no tiene preguntas generadas'
|
||||
], 400);
|
||||
}
|
||||
|
||||
// Verificar que no exceda el número máximo de intentos
|
||||
if ($areaProceso && $examen->intentos >= $areaProceso->proceso_intentos_maximos) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => 'Has alcanzado el número máximo de intentos para este proceso'
|
||||
], 403);
|
||||
}
|
||||
|
||||
// Marcar hora inicio si no está iniciado
|
||||
if (!$examen->hora_inicio) {
|
||||
$examen->update([
|
||||
'hora_inicio' => now(),
|
||||
'estado' => 'en_progreso',
|
||||
'intentos' => $examen->intentos + 1
|
||||
]);
|
||||
}
|
||||
|
||||
// Obtener preguntas con toda la información
|
||||
$preguntas = $this->examenService->obtenerPreguntasExamen($examen);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'examen' => [
|
||||
'id' => $examen->id,
|
||||
'estado' => $examen->estado,
|
||||
'hora_inicio' => $examen->hora_inicio,
|
||||
'intentos' => $examen->intentos,
|
||||
'intentos_maximos' => $areaProceso->proceso_intentos_maximos ?? null,
|
||||
'proceso' => $areaProceso->proceso_nombre ?? null,
|
||||
'duracion' => $areaProceso->proceso_duracion ?? null,
|
||||
'area' => $areaProceso->area_nombre ?? null
|
||||
],
|
||||
'preguntas' => $preguntas
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 5. RESPONDER PREGUNTA
|
||||
*/
|
||||
public function responderPregunta($preguntaAsignadaId, Request $request)
|
||||
{
|
||||
$request->validate([
|
||||
'respuesta' => 'required|string'
|
||||
]);
|
||||
|
||||
$preguntaAsignada = PreguntaAsignada::with(['examen', 'pregunta'])
|
||||
->findOrFail($preguntaAsignadaId);
|
||||
|
||||
// Verificar que pertenece al usuario
|
||||
$postulante = $request->user();
|
||||
if ($preguntaAsignada->examen->postulante_id !== $postulante->id) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => 'No autorizado'
|
||||
], 403);
|
||||
}
|
||||
|
||||
// Guardar respuesta
|
||||
$resultado = $this->examenService->guardarRespuesta(
|
||||
$preguntaAsignada,
|
||||
$request->respuesta
|
||||
);
|
||||
|
||||
return response()->json($resultado);
|
||||
}
|
||||
|
||||
/**
|
||||
* 6. FINALIZAR EXAMEN
|
||||
*/
|
||||
public function finalizarExamen($examenId)
|
||||
{
|
||||
$examen = Examen::findOrFail($examenId);
|
||||
|
||||
// Verificar que el examen pertenece al usuario autenticado
|
||||
$postulante = request()->user();
|
||||
if ($examen->postulante_id !== $postulante->id) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => 'No autorizado'
|
||||
], 403);
|
||||
}
|
||||
|
||||
$this->examenService->finalizarExamen($examen);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => 'Examen finalizado exitosamente'
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class AreaAdmision extends Model
|
||||
{
|
||||
protected $table = 'areas_admision';
|
||||
|
||||
protected $fillable = [
|
||||
'nombre',
|
||||
'descripcion',
|
||||
'estado'
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'estado' => 'boolean'
|
||||
];
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| RELACIONES
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
// Un área tiene muchos resultados
|
||||
public function resultados()
|
||||
{
|
||||
return $this->hasMany(ResultadoAdmision::class, 'idearea');
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,69 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class Examen extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $table = 'examenes';
|
||||
|
||||
protected $fillable = [
|
||||
'postulante_id',
|
||||
'area_proceso_id',
|
||||
'pagado',
|
||||
'tipo_pago',
|
||||
'pago_id',
|
||||
'intentos',
|
||||
'hora_inicio',
|
||||
|
||||
];
|
||||
|
||||
public function postulante()
|
||||
{
|
||||
return $this->belongsTo(Postulante::class, 'postulante_id');
|
||||
}
|
||||
|
||||
|
||||
// public function area()
|
||||
// {
|
||||
// return $this->belongsTo(Area::class, 'area_id');
|
||||
// }
|
||||
|
||||
public function areaProceso()
|
||||
{
|
||||
return $this->belongsTo(AreaProceso::class, 'area_proceso_id');
|
||||
}
|
||||
|
||||
public function pago()
|
||||
{
|
||||
return $this->belongsTo(Pago::class, 'pago_id');
|
||||
}
|
||||
|
||||
// Accesos rápidos opcionales
|
||||
public function area()
|
||||
{
|
||||
return $this->hasOneThrough(
|
||||
Area::class,
|
||||
AreaProceso::class,
|
||||
'id', // Foreign key on AreaProceso table...
|
||||
'id', // Foreign key on Area table...
|
||||
'area_proceso_id', // Local key on Examen table...
|
||||
'area_id' // Local key on AreaProceso table...
|
||||
);
|
||||
}
|
||||
|
||||
public function proceso()
|
||||
{
|
||||
return $this->areaProceso->proceso;
|
||||
}
|
||||
|
||||
public function preguntasAsignadas()
|
||||
{
|
||||
return $this->hasMany(PreguntaAsignada::class, 'examen_id');
|
||||
}
|
||||
|
||||
}
|
||||
@ -0,0 +1,23 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class Pago extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $fillable = [
|
||||
'postulante_id',
|
||||
'tipo_pago',
|
||||
'codigo_pago',
|
||||
'monto',
|
||||
'utilizado',
|
||||
'original_date',
|
||||
'confirmed_date',
|
||||
'fecha_pago',
|
||||
];
|
||||
}
|
||||
|
||||
@ -0,0 +1,51 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Foundation\Auth\User as Authenticatable; // Para usar login
|
||||
use Laravel\Sanctum\HasApiTokens; // Para tokens API
|
||||
use Illuminate\Notifications\Notifiable;
|
||||
|
||||
class Postulante extends Authenticatable
|
||||
{
|
||||
use HasApiTokens, HasFactory, Notifiable;
|
||||
|
||||
protected $table = 'postulantes';
|
||||
|
||||
/**
|
||||
* Campos asignables
|
||||
*/
|
||||
protected $fillable = [
|
||||
'name',
|
||||
'email',
|
||||
'password',
|
||||
'dni',
|
||||
'device_id',
|
||||
'last_activity'
|
||||
];
|
||||
|
||||
/**
|
||||
* Campos ocultos al serializar
|
||||
*/
|
||||
protected $hidden = [
|
||||
'password',
|
||||
'device_id',
|
||||
'tokens'
|
||||
];
|
||||
|
||||
/**
|
||||
* Casting
|
||||
*/
|
||||
protected $casts = [
|
||||
'last_activity' => 'datetime',
|
||||
];
|
||||
|
||||
/**
|
||||
* Mutator para encriptar la contraseña automáticamente
|
||||
*/
|
||||
public function setPasswordAttribute($value)
|
||||
{
|
||||
$this->attributes['password'] = bcrypt($value);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,39 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class PreguntaAsignada extends Model
|
||||
{
|
||||
protected $table = 'preguntas_asignadas';
|
||||
|
||||
protected $fillable = [
|
||||
'examen_id',
|
||||
'pregunta_id',
|
||||
'orden',
|
||||
'respuesta_usuario',
|
||||
'es_correcta',
|
||||
'estado',
|
||||
'puntaje',
|
||||
'respondida_at'
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'es_correcta' => 'boolean',
|
||||
'puntaje' => 'decimal:2'
|
||||
];
|
||||
|
||||
protected $dates = ['respondida_at'];
|
||||
|
||||
public function examen(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Examen::class);
|
||||
}
|
||||
|
||||
public function pregunta(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Pregunta::class);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,35 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class ProcesoAdmision extends Model
|
||||
{
|
||||
protected $table = 'procesos_admision';
|
||||
|
||||
protected $fillable = [
|
||||
'nombre',
|
||||
'fecha_inicio',
|
||||
'fecha_fin',
|
||||
'estado'
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'fecha_inicio' => 'datetime',
|
||||
'fecha_fin' => 'datetime',
|
||||
'estado' => 'boolean'
|
||||
];
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| RELACIONES
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
// Un proceso tiene muchos resultados
|
||||
public function resultados()
|
||||
{
|
||||
return $this->hasMany(ResultadoAdmision::class, 'idproceso');
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class ReglaAreaProceso extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $table = 'reglas_area_proceso';
|
||||
|
||||
protected $fillable = [
|
||||
'area_proceso_id',
|
||||
'curso_id',
|
||||
'cantidad_preguntas',
|
||||
'orden',
|
||||
'nivel_dificultad',
|
||||
'ponderacion',
|
||||
];
|
||||
|
||||
public function areaProceso()
|
||||
{
|
||||
return $this->belongsTo(AreaProceso::class);
|
||||
}
|
||||
|
||||
public function curso()
|
||||
{
|
||||
return $this->belongsTo(Curso::class);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,62 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class ResultadoAdmision extends Model
|
||||
{
|
||||
protected $table = 'resultados_admision';
|
||||
|
||||
protected $fillable = [
|
||||
'dni',
|
||||
'paterno',
|
||||
'materno',
|
||||
'nombres',
|
||||
'puntaje',
|
||||
'vocacional',
|
||||
'apto',
|
||||
'obs',
|
||||
'desprograma',
|
||||
'idproceso',
|
||||
'idearea',
|
||||
'litho',
|
||||
'numlectura',
|
||||
'tipo',
|
||||
'calificar',
|
||||
'aula',
|
||||
'respuestas',
|
||||
'puesto'
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'puntaje' => 'decimal:2',
|
||||
'vocacional' => 'decimal:2',
|
||||
'desprograma' => 'boolean',
|
||||
'calificar' => 'boolean',
|
||||
'respuestas' => 'array'
|
||||
];
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| RELACIONES
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
public function proceso()
|
||||
{
|
||||
return $this->belongsTo(ProcesoAdmision::class, 'idproceso');
|
||||
}
|
||||
|
||||
public function area()
|
||||
{
|
||||
return $this->belongsTo(AreaAdmision::class, 'idearea');
|
||||
}
|
||||
|
||||
public function detalleCursos()
|
||||
{
|
||||
return $this->hasOne(ResultadoAdmisionCarga::class, 'dni', 'dni')
|
||||
->whereColumn('idproceso', 'resultados_admision.idproceso')
|
||||
->whereColumn('idearea', 'resultados_admision.idearea');
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,148 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class ResultadoAdmisionCarga extends Model
|
||||
{
|
||||
protected $table = 'resultados_admision_carga';
|
||||
|
||||
protected $primaryKey = 'id';
|
||||
|
||||
public $timestamps = false; // Solo tienes created_at
|
||||
|
||||
protected $guarded = [];
|
||||
// Uso guarded vacío porque tienes MUCHOS campos.
|
||||
// Es más práctico que escribir todos en fillable.
|
||||
|
||||
protected $casts = [
|
||||
|
||||
'puntaje_total' => 'decimal:2',
|
||||
'puesto' => 'integer',
|
||||
|
||||
// ARITMETICA
|
||||
'correctas_aritmetica' => 'integer',
|
||||
'blancas_aritmetica' => 'integer',
|
||||
'puntaje_aritmetica' => 'decimal:2',
|
||||
'porcentaje_aritmetica' => 'decimal:2',
|
||||
|
||||
// ALGEBRA
|
||||
'correctas_algebra' => 'integer',
|
||||
'blancas_algebra' => 'integer',
|
||||
'puntaje_algebra' => 'decimal:2',
|
||||
'porcentaje_algebra' => 'decimal:2',
|
||||
|
||||
// GEOMETRIA
|
||||
'correctas_geometria' => 'integer',
|
||||
'blancas_geometria' => 'integer',
|
||||
'puntaje_geometria' => 'decimal:2',
|
||||
'porcentaje_geometria' => 'decimal:2',
|
||||
|
||||
// TRIGONOMETRIA
|
||||
'correctas_trigonometria' => 'integer',
|
||||
'blancas_trigonometria' => 'integer',
|
||||
'puntaje_trigonometria' => 'decimal:2',
|
||||
'porcentaje_trigonometria' => 'decimal:2',
|
||||
|
||||
// FISICA
|
||||
'correctas_fisica' => 'integer',
|
||||
'blancas_fisica' => 'integer',
|
||||
'puntaje_fisica' => 'decimal:2',
|
||||
'porcentaje_fisica' => 'decimal:2',
|
||||
|
||||
// QUIMICA
|
||||
'correctas_quimica' => 'integer',
|
||||
'blancas_quimica' => 'integer',
|
||||
'puntaje_quimica' => 'decimal:2',
|
||||
'porcentaje_quimica' => 'decimal:2',
|
||||
|
||||
// BIOLOGIA
|
||||
'correctas_biologia_anatomia' => 'integer',
|
||||
'blancas_biologia_anatomia' => 'integer',
|
||||
'puntaje_biologia_anatomia' => 'decimal:2',
|
||||
'porcentaje_biologia_anatomia' => 'decimal:2',
|
||||
|
||||
// PSICOLOGIA
|
||||
'correctas_psicologia_filosofia' => 'integer',
|
||||
'blancas_psicologia_filosofia' => 'integer',
|
||||
'puntaje_psicologia_filosofia' => 'decimal:2',
|
||||
'porcentaje_psicologia_filosofia' => 'decimal:2',
|
||||
|
||||
// GEOGRAFIA
|
||||
'correctas_geografia' => 'integer',
|
||||
'blancas_geografia' => 'integer',
|
||||
'puntaje_geografia' => 'decimal:2',
|
||||
'porcentaje_geografia' => 'decimal:2',
|
||||
|
||||
// HISTORIA
|
||||
'correctas_historia' => 'integer',
|
||||
'blancas_historia' => 'integer',
|
||||
'puntaje_historia' => 'decimal:2',
|
||||
'porcentaje_historia' => 'decimal:2',
|
||||
|
||||
// EDUCACION CIVICA
|
||||
'correctas_educacion_civica' => 'integer',
|
||||
'blancas_educacion_civica' => 'integer',
|
||||
'puntaje_educacion_civica' => 'decimal:2',
|
||||
'porcentaje_educacion_civica' => 'decimal:2',
|
||||
|
||||
// ECONOMIA
|
||||
'correctas_economia' => 'integer',
|
||||
'blancas_economia' => 'integer',
|
||||
'puntaje_economia' => 'decimal:2',
|
||||
'porcentaje_economia' => 'decimal:2',
|
||||
|
||||
// COMUNICACION
|
||||
'correctas_comunicacion' => 'integer',
|
||||
'blancas_comunicacion' => 'integer',
|
||||
'puntaje_comunicacion' => 'decimal:2',
|
||||
'porcentaje_comunicacion' => 'decimal:2',
|
||||
|
||||
// LITERATURA
|
||||
'correctas_literatura' => 'integer',
|
||||
'blancas_literatura' => 'integer',
|
||||
'puntaje_literatura' => 'decimal:2',
|
||||
'porcentaje_literatura' => 'decimal:2',
|
||||
|
||||
// RAZONAMIENTO MATEMATICO
|
||||
'correctas_razonamiento_matematico' => 'integer',
|
||||
'blancas_razonamiento_matematico' => 'integer',
|
||||
'puntaje_razonamiento_matematico' => 'decimal:2',
|
||||
'porcentaje_razonamiento_matematico' => 'decimal:2',
|
||||
|
||||
// RAZONAMIENTO VERBAL
|
||||
'correctas_razonamiento_verbal' => 'integer',
|
||||
'blancas_razonamiento_verbal' => 'integer',
|
||||
'puntaje_razonamiento_verbal' => 'decimal:2',
|
||||
'porcentaje_razonamiento_verbal' => 'decimal:2',
|
||||
|
||||
// INGLES
|
||||
'correctas_ingles' => 'integer',
|
||||
'blancas_ingles' => 'integer',
|
||||
'puntaje_ingles' => 'decimal:2',
|
||||
'porcentaje_ingles' => 'decimal:2',
|
||||
|
||||
// QUECHUA / AIMARA
|
||||
'correctas_quechua_aimara' => 'integer',
|
||||
'blancas_quechua_aimara' => 'integer',
|
||||
'puntaje_quechua_aimara' => 'decimal:2',
|
||||
'porcentaje_quechua_aimara' => 'decimal:2',
|
||||
];
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| RELACIONES
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
public function proceso()
|
||||
{
|
||||
return $this->belongsTo(ProcesoAdmision::class, 'idproceso');
|
||||
}
|
||||
|
||||
public function area()
|
||||
{
|
||||
return $this->belongsTo(AreaAdmision::class, 'idearea');
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,164 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\Examen;
|
||||
use App\Models\Pregunta;
|
||||
use App\Models\ReglaAreaProceso;
|
||||
use App\Models\PreguntaAsignada;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class ExamenService
|
||||
{
|
||||
/**
|
||||
* Generar preguntas según reglas
|
||||
*/
|
||||
public function generarPreguntasExamen(Examen $examen): array
|
||||
{
|
||||
if ($examen->preguntasAsignadas()->exists()) {
|
||||
return [
|
||||
'success' => false,
|
||||
'message' => 'El examen ya tiene preguntas'
|
||||
];
|
||||
}
|
||||
|
||||
$reglas = ReglaAreaProceso::where('area_proceso_id', $examen->area_proceso_id)
|
||||
->orderBy('orden')
|
||||
->get();
|
||||
|
||||
if ($reglas->isEmpty()) {
|
||||
return [
|
||||
'success' => false,
|
||||
'message' => 'No hay reglas configuradas'
|
||||
];
|
||||
}
|
||||
|
||||
DB::beginTransaction();
|
||||
|
||||
try {
|
||||
$orden = 1;
|
||||
|
||||
foreach ($reglas as $regla) {
|
||||
$preguntas = Pregunta::where('curso_id', $regla->curso_id)
|
||||
->where('activo', 1)
|
||||
->when($regla->nivel_dificultad, fn ($q) =>
|
||||
$q->where('nivel_dificultad', $regla->nivel_dificultad)
|
||||
)
|
||||
->inRandomOrder()
|
||||
->limit($regla->cantidad_preguntas)
|
||||
->get();
|
||||
|
||||
if ($preguntas->count() < $regla->cantidad_preguntas) {
|
||||
throw new \Exception("Preguntas insuficientes para curso {$regla->curso_id}");
|
||||
}
|
||||
|
||||
foreach ($preguntas as $pregunta) {
|
||||
PreguntaAsignada::create([
|
||||
'examen_id' => $examen->id,
|
||||
'pregunta_id' => $pregunta->id,
|
||||
'orden' => $orden++,
|
||||
'puntaje_base' => $regla->ponderacion,
|
||||
'estado' => 'pendiente',
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
DB::commit();
|
||||
|
||||
return ['success' => true];
|
||||
|
||||
} catch (\Throwable $e) {
|
||||
DB::rollBack();
|
||||
return ['success' => false, 'message' => $e->getMessage()];
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
public function obtenerPreguntasExamen(Examen $examen): array
|
||||
{
|
||||
// Traemos preguntas con curso
|
||||
$preguntas = $examen->preguntasAsignadas()
|
||||
->with('pregunta.curso')
|
||||
->get()
|
||||
->sortBy('orden');
|
||||
|
||||
// Traemos datos del área-proceso directamente desde la DB
|
||||
$areaProceso = \DB::table('area_proceso')
|
||||
->join('procesos', 'area_proceso.proceso_id', '=', 'procesos.id')
|
||||
->join('areas', 'area_proceso.area_id', '=', 'areas.id')
|
||||
->where('area_proceso.id', $examen->area_proceso_id)
|
||||
->select(
|
||||
'procesos.nombre as proceso_nombre',
|
||||
'procesos.duracion as proceso_duracion',
|
||||
'procesos.intentos_maximos as proceso_intentos_maximos',
|
||||
'areas.nombre as area_nombre'
|
||||
)
|
||||
->first();
|
||||
|
||||
return $preguntas->map(fn($pa) => [
|
||||
'id' => $pa->id,
|
||||
'orden' => $pa->orden,
|
||||
'enunciado' => $pa->pregunta->enunciado,
|
||||
'extra' => $pa->pregunta->enunciado_adicional,
|
||||
'opciones' => $this->mezclarOpciones($pa->pregunta->opciones),
|
||||
'imagenes' => $pa->pregunta->imagenes,
|
||||
'estado' => $pa->estado,
|
||||
'respuesta' => $pa->pregunta->respuesta_correcta,
|
||||
'curso' => $pa->pregunta->curso->nombre ?? null,
|
||||
'proceso' => $areaProceso->proceso_nombre ?? null,
|
||||
'duracion' => $areaProceso->proceso_duracion ?? null,
|
||||
'intentos_maximos'=> $areaProceso->proceso_intentos_maximos ?? null,
|
||||
'area' => $areaProceso->area_nombre ?? null
|
||||
])->values()->toArray();
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
public function guardarRespuesta(PreguntaAsignada $pa, string $respuesta): array
|
||||
{
|
||||
if ($pa->estado === 'respondida') {
|
||||
return ['success' => false, 'message' => 'Ya respondida'];
|
||||
}
|
||||
|
||||
$esCorrecta = $respuesta === $pa->pregunta->respuesta_correcta;
|
||||
|
||||
$pa->update([
|
||||
'respuesta_usuario' => $respuesta,
|
||||
'es_correcta' => $esCorrecta,
|
||||
'puntaje_obtenido' => $esCorrecta ? $pa->puntaje_base : 0,
|
||||
'estado' => 'respondida',
|
||||
'respondida_at' => now()
|
||||
]);
|
||||
|
||||
return [
|
||||
'success' => true,
|
||||
'correcta' => $esCorrecta,
|
||||
'puntaje' => $pa->puntaje_obtenido
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Finalizar examen
|
||||
*/
|
||||
public function finalizarExamen(Examen $examen): void
|
||||
{
|
||||
$examen->update([
|
||||
'estado' => 'finalizado',
|
||||
'hora_fin' => now()
|
||||
]);
|
||||
}
|
||||
|
||||
private function mezclarOpciones(?array $opciones): array
|
||||
{
|
||||
if (!$opciones) return [];
|
||||
|
||||
$keys = array_keys($opciones);
|
||||
shuffle($keys);
|
||||
|
||||
return array_map(fn ($k) => [
|
||||
'key' => $k,
|
||||
'texto' => $opciones[$k]
|
||||
], $keys);
|
||||
}
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 47 KiB |
@ -0,0 +1,32 @@
|
||||
import axios from 'axios'
|
||||
import router from './router'
|
||||
import { useAuthStore } from './store/postulanteStore'
|
||||
|
||||
const apiPostulante = axios.create({
|
||||
baseURL: import.meta.env.VITE_API_URL,
|
||||
headers: { 'Content-Type': 'application/json', Accept: 'application/json' }
|
||||
})
|
||||
|
||||
// Interceptor para agregar token del postulante
|
||||
apiPostulante.interceptors.request.use(config => {
|
||||
const postulanteStore = useAuthStore()
|
||||
if (postulanteStore.token) {
|
||||
config.headers.Authorization = `Bearer ${postulanteStore.token}`
|
||||
}
|
||||
return config
|
||||
})
|
||||
|
||||
// Manejo de errores (401)
|
||||
apiPostulante.interceptors.response.use(
|
||||
response => response,
|
||||
error => {
|
||||
const postulanteStore = useAuthStore()
|
||||
if (error.response?.status === 401) {
|
||||
postulanteStore.logout()
|
||||
router.push('/login-postulante')
|
||||
}
|
||||
return Promise.reject(error)
|
||||
}
|
||||
)
|
||||
|
||||
export default apiPostulante
|
||||
File diff suppressed because it is too large
Load Diff
@ -1,615 +0,0 @@
|
||||
<!-- components/Postulante/VinculacionAcademia.vue -->
|
||||
<template>
|
||||
<div class="vinculacion-modal">
|
||||
<div class="modal-content">
|
||||
<!-- Paso 1: Ingresar código -->
|
||||
<div class="step-ingreso" v-if="paso === 1">
|
||||
<div class="step-header">
|
||||
<div class="step-number active">1</div>
|
||||
<div class="step-info">
|
||||
<h3>Ingresa el código de academia</h3>
|
||||
<p>Pide el código a tu profesor o coordinador</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<a-form
|
||||
:model="formState"
|
||||
layout="vertical"
|
||||
@finish="validarCodigo"
|
||||
class="codigo-form"
|
||||
>
|
||||
<a-form-item
|
||||
label="Código de Academia"
|
||||
:rules="[
|
||||
{ required: true, message: 'Ingresa el código' },
|
||||
{ pattern: /^[A-Z0-9\-]+$/, message: 'Solo letras mayúsculas, números y guiones' }
|
||||
]"
|
||||
>
|
||||
<a-input
|
||||
v-model:value="formState.codigo"
|
||||
placeholder="Ej: AC001-2024"
|
||||
size="large"
|
||||
:disabled="vinculacionStore.isLoading"
|
||||
@input="formatearCodigo"
|
||||
>
|
||||
<template #prefix>
|
||||
<KeyOutlined style="color: #666" />
|
||||
</template>
|
||||
<template #suffix>
|
||||
<a-tooltip title="¿Cómo obtener el código?">
|
||||
<QuestionCircleOutlined
|
||||
@click="mostrarAyuda"
|
||||
style="cursor: pointer; color: #666"
|
||||
/>
|
||||
</a-tooltip>
|
||||
</template>
|
||||
</a-input>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item>
|
||||
<a-button
|
||||
type="primary"
|
||||
html-type="submit"
|
||||
size="large"
|
||||
:loading="vinculacionStore.isLoading"
|
||||
block
|
||||
>
|
||||
Validar Código
|
||||
</a-button>
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
|
||||
<!-- Códigos recientes -->
|
||||
<div class="codigos-recientes" v-if="codigosRecientes.length > 0">
|
||||
<h4>Códigos recientes</h4>
|
||||
<a-space :size="8" wrap>
|
||||
<a-tag
|
||||
v-for="codigo in codigosRecientes"
|
||||
:key="codigo"
|
||||
color="blue"
|
||||
style="cursor: pointer"
|
||||
@click="usarCodigoReciente(codigo)"
|
||||
>
|
||||
{{ codigo }}
|
||||
<CloseOutlined @click.stop="eliminarCodigoReciente(codigo)" />
|
||||
</a-tag>
|
||||
</a-space>
|
||||
</div>
|
||||
|
||||
<!-- Ayuda QR -->
|
||||
<div class="qr-section">
|
||||
<p class="qr-title">
|
||||
<QrcodeOutlined /> También puedes usar QR
|
||||
</p>
|
||||
<div class="qr-actions">
|
||||
<a-button @click="escanearQR">
|
||||
<CameraOutlined />
|
||||
Escanear QR
|
||||
</a-button>
|
||||
<a-button @click="mostrarEjemploQR">
|
||||
<EyeOutlined />
|
||||
Ver ejemplo
|
||||
</a-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Paso 2: Confirmar vinculación -->
|
||||
<div class="step-confirmacion" v-else-if="paso === 2 && vinculacionStore.codigoValidado">
|
||||
<div class="step-header">
|
||||
<div class="step-number active">2</div>
|
||||
<div class="step-info">
|
||||
<h3>Confirmar vinculación</h3>
|
||||
<p>Revisa los detalles antes de vincular</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<a-card class="academia-info-card">
|
||||
<a-space direction="vertical" size="large" style="width: 100%">
|
||||
<!-- Información de la academia -->
|
||||
<div class="academia-details">
|
||||
<div class="academia-header">
|
||||
<a-avatar :size="48" :style="{ backgroundColor: stringToColor(vinculacionStore.codigoValidado.nombre) }">
|
||||
{{ vinculacionStore.codigoValidado.nombre.charAt(0).toUpperCase() }}
|
||||
</a-avatar>
|
||||
<div class="academia-titulo">
|
||||
<h3 style="margin: 0">{{ vinculacionStore.codigoValidado.nombre }}</h3>
|
||||
<p style="margin: 0; color: #666">
|
||||
Código: <strong>{{ vinculacionStore.codigoValidado.codigo }}</strong>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<a-divider style="margin: 16px 0" />
|
||||
|
||||
<!-- Estadísticas -->
|
||||
<a-row :gutter="16" class="academia-stats">
|
||||
<a-col :span="12">
|
||||
<div class="stat-item">
|
||||
<TeamOutlined style="color: #1890ff; font-size: 20px" />
|
||||
<div class="stat-info">
|
||||
<div class="stat-value">{{ vinculacionStore.codigoValidado.estudiantes_activos }}</div>
|
||||
<div class="stat-label">Estudiantes</div>
|
||||
</div>
|
||||
</div>
|
||||
</a-col>
|
||||
<a-col :span="12">
|
||||
<div class="stat-item">
|
||||
<FileTextOutlined style="color: #52c41a; font-size: 20px" />
|
||||
<div class="stat-info">
|
||||
<div class="stat-value">+{{ examenesCount }}</div>
|
||||
<div class="stat-label">Exámenes</div>
|
||||
</div>
|
||||
</div>
|
||||
</a-col>
|
||||
</a-row>
|
||||
|
||||
<!-- Límite de estudiantes -->
|
||||
<a-alert
|
||||
v-if="vinculacionStore.codigoValidado.estudiantes_activos >= vinculacionStore.codigoValidado.limite_estudiantes * 0.9"
|
||||
type="warning"
|
||||
message="Cupo casi lleno"
|
||||
:description="`${vinculacionStore.codigoValidado.estudiantes_activos}/${vinculacionStore.codigoValidado.limite_estudiantes} estudiantes`"
|
||||
show-icon
|
||||
style="margin-top: 16px"
|
||||
/>
|
||||
|
||||
<!-- Estado -->
|
||||
<div class="academia-status">
|
||||
<a-tag :color="vinculacionStore.codigoValidado.activa ? 'green' : 'red'">
|
||||
{{ vinculacionStore.codigoValidado.activa ? 'Activa' : 'Inactiva' }}
|
||||
</a-tag>
|
||||
<span v-if="vinculacionStore.codigoValidado.ya_vinculado" class="ya-vinculado">
|
||||
<CheckCircleOutlined /> Ya estás vinculado
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Acciones -->
|
||||
<div class="acciones-confirmacion">
|
||||
<a-space :size="16" style="width: 100%">
|
||||
<a-button
|
||||
@click="paso = 1"
|
||||
:disabled="vinculacionStore.isLoading"
|
||||
block
|
||||
>
|
||||
<LeftOutlined />
|
||||
Volver
|
||||
</a-button>
|
||||
<a-button
|
||||
type="primary"
|
||||
@click="confirmarVinculacion"
|
||||
:loading="vinculacionStore.isLoading"
|
||||
:disabled="
|
||||
vinculacionStore.codigoValidado.ya_vinculado ||
|
||||
!vinculacionStore.codigoValidado.activa ||
|
||||
vinculacionStore.codigoValidado.estudiantes_activos >= vinculacionStore.codigoValidado.limite_estudiantes
|
||||
"
|
||||
|
||||
block
|
||||
>
|
||||
<template v-if="vinculacionStore.codigoValidado.ya_vinculado">
|
||||
Ya vinculado
|
||||
</template>
|
||||
<template v-else>
|
||||
<LinkOutlined />
|
||||
Vincularme
|
||||
</template>
|
||||
</a-button>
|
||||
</a-space>
|
||||
</div>
|
||||
</a-space>
|
||||
</a-card>
|
||||
</div>
|
||||
|
||||
<!-- Paso 3: Éxito -->
|
||||
<div class="step-exito" v-else-if="paso === 3">
|
||||
<div class="exito-content">
|
||||
<div class="exito-icon">
|
||||
<CheckCircleOutlined style="font-size: 64px; color: #52c41a" />
|
||||
</div>
|
||||
<h2>¡Vinculación exitosa!</h2>
|
||||
<p>Ahora formas parte de <strong>{{ academiaVinculada?.nombre }}</strong></p>
|
||||
|
||||
<div class="exito-details">
|
||||
<a-card size="small">
|
||||
<a-list size="small">
|
||||
<a-list-item>
|
||||
<template #actions>
|
||||
<a-tag color="blue">{{ academiaVinculada?.codigo }}</a-tag>
|
||||
</template>
|
||||
<a-list-item-meta>
|
||||
<template #title>
|
||||
Código de academia
|
||||
</template>
|
||||
</a-list-item-meta>
|
||||
</a-list-item>
|
||||
<a-list-item>
|
||||
<template #actions>
|
||||
<span>{{ examenesDisponibles }} disponibles</span>
|
||||
</template>
|
||||
<a-list-item-meta>
|
||||
<template #title>
|
||||
Exámenes
|
||||
</template>
|
||||
</a-list-item-meta>
|
||||
</a-list-item>
|
||||
</a-list>
|
||||
</a-card>
|
||||
</div>
|
||||
|
||||
<div class="exito-acciones">
|
||||
<a-space :size="16" style="width: 100%">
|
||||
<a-button @click="cerrarModal" block>
|
||||
Continuar
|
||||
</a-button>
|
||||
<a-button type="primary" @click="irAExamenes" block>
|
||||
<EyeOutlined />
|
||||
Ver exámenes
|
||||
</a-button>
|
||||
</a-space>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Error -->
|
||||
<div class="step-error" v-else-if="paso === 'error'">
|
||||
<div class="error-content">
|
||||
<div class="error-icon">
|
||||
<CloseCircleOutlined style="font-size: 64px; color: #ff4d4f" />
|
||||
</div>
|
||||
<h2>Error en la vinculación</h2>
|
||||
<p>{{ errorMensaje }}</p>
|
||||
|
||||
<div class="error-sugerencias">
|
||||
<a-alert
|
||||
type="info"
|
||||
message="Sugerencias:"
|
||||
description="1. Verifica que el código sea correcto
|
||||
2. Asegúrate de que la academia esté activa
|
||||
3. Contacta al administrador de la academia"
|
||||
show-icon
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="error-acciones">
|
||||
<a-space :size="16" style="width: 100%">
|
||||
<a-button @click="reiniciarProceso" type="primary" block>
|
||||
<ReloadOutlined />
|
||||
Intentar nuevamente
|
||||
</a-button>
|
||||
<a-button @click="cerrarModal" block>
|
||||
Cancelar
|
||||
</a-button>
|
||||
</a-space>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, onMounted } from 'vue'
|
||||
import { message } from 'ant-design-vue'
|
||||
import { useVinculacionStore } from '../../store/vinculacion'
|
||||
import {
|
||||
KeyOutlined,
|
||||
QuestionCircleOutlined,
|
||||
CloseOutlined,
|
||||
QrcodeOutlined,
|
||||
CameraOutlined,
|
||||
EyeOutlined,
|
||||
TeamOutlined,
|
||||
FileTextOutlined,
|
||||
LeftOutlined,
|
||||
LinkOutlined,
|
||||
CheckCircleOutlined,
|
||||
CloseCircleOutlined,
|
||||
ReloadOutlined
|
||||
} from '@ant-design/icons-vue'
|
||||
|
||||
const vinculacionStore = useVinculacionStore()
|
||||
const emit = defineEmits(['cerrar'])
|
||||
|
||||
const paso = ref(1)
|
||||
const formState = reactive({
|
||||
codigo: ''
|
||||
})
|
||||
const codigosRecientes = ref([])
|
||||
const academiaVinculada = ref(null)
|
||||
const errorMensaje = ref('')
|
||||
const examenesCount = ref(15) // Esto vendría del backend
|
||||
|
||||
onMounted(() => {
|
||||
cargarCodigosRecientes()
|
||||
})
|
||||
|
||||
const cargarCodigosRecientes = () => {
|
||||
const codigos = localStorage.getItem('codigos_recientes')
|
||||
if (codigos) {
|
||||
codigosRecientes.value = JSON.parse(codigos)
|
||||
}
|
||||
}
|
||||
|
||||
const guardarCodigoReciente = (codigo) => {
|
||||
const codigos = new Set([codigo, ...codigosRecientes.value])
|
||||
codigosRecientes.value = Array.from(codigos).slice(0, 5)
|
||||
localStorage.setItem('codigos_recientes', JSON.stringify(codigosRecientes.value))
|
||||
}
|
||||
|
||||
const eliminarCodigoReciente = (codigo) => {
|
||||
codigosRecientes.value = codigosRecientes.value.filter(c => c !== codigo)
|
||||
localStorage.setItem('codigos_recientes', JSON.stringify(codigosRecientes.value))
|
||||
}
|
||||
|
||||
const usarCodigoReciente = (codigo) => {
|
||||
formState.codigo = codigo
|
||||
validarCodigo()
|
||||
}
|
||||
|
||||
const formatearCodigo = () => {
|
||||
formState.codigo = formState.codigo.toUpperCase().replace(/[^A-Z0-9\-]/g, '')
|
||||
}
|
||||
|
||||
const validarCodigo = async () => {
|
||||
if (!formState.codigo.trim()) {
|
||||
message.error('Ingresa un código válido')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await vinculacionStore.validarCodigo(formState.codigo)
|
||||
guardarCodigoReciente(formState.codigo)
|
||||
paso.value = 2
|
||||
} catch (error) {
|
||||
// Error manejado en el store
|
||||
}
|
||||
}
|
||||
|
||||
const confirmarVinculacion = async () => {
|
||||
try {
|
||||
const resultado = await vinculacionStore.vincularAcademia(formState.codigo)
|
||||
academiaVinculada.value = resultado.academia
|
||||
paso.value = 3
|
||||
} catch (error) {
|
||||
paso.value = 'error'
|
||||
errorMensaje.value = vinculacionStore.getError || 'Error al vincular con la academia'
|
||||
}
|
||||
}
|
||||
|
||||
const mostrarAyuda = () => {
|
||||
message.info('El código te lo debe proporcionar tu profesor o coordinador de la academia')
|
||||
}
|
||||
|
||||
const escanearQR = () => {
|
||||
message.info('Función de escaneo de QR en desarrollo')
|
||||
}
|
||||
|
||||
const mostrarEjemploQR = () => {
|
||||
message.info('El QR contiene el código de la academia para escanear')
|
||||
}
|
||||
|
||||
const stringToColor = (str) => {
|
||||
let hash = 0
|
||||
for (let i = 0; i < str.length; i++) {
|
||||
hash = str.charCodeAt(i) + ((hash << 5) - hash)
|
||||
}
|
||||
const colors = ['#1890ff', '#52c41a', '#722ed1', '#fa8c16', '#f5222d']
|
||||
return colors[Math.abs(hash) % colors.length]
|
||||
}
|
||||
|
||||
const cerrarModal = () => {
|
||||
vinculacionStore.hideModalVinculacion()
|
||||
reiniciarProceso()
|
||||
}
|
||||
|
||||
const irAExamenes = () => {
|
||||
cerrarModal()
|
||||
// Navegar a exámenes
|
||||
router.push('/postulante/examenes')
|
||||
}
|
||||
|
||||
const reiniciarProceso = () => {
|
||||
paso.value = 1
|
||||
formState.codigo = ''
|
||||
academiaVinculada.value = null
|
||||
errorMensaje.value = ''
|
||||
}
|
||||
|
||||
const examenesDisponibles = ref(3) // Esto vendría del backend
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.vinculacion-modal {
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
max-width: 500px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
/* Pasos */
|
||||
.step-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 16px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.step-number {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-weight: 600;
|
||||
background: #f0f0f0;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.step-number.active {
|
||||
background: #1890ff;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.step-info h3 {
|
||||
margin: 0 0 4px 0;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.step-info p {
|
||||
margin: 0;
|
||||
color: #666;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
/* Formulario */
|
||||
.codigo-form {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
/* Códigos recientes */
|
||||
.codigos-recientes {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.codigos-recientes h4 {
|
||||
margin: 0 0 8px 0;
|
||||
font-size: 14px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
/* QR Section */
|
||||
.qr-section {
|
||||
background: #fafafa;
|
||||
border-radius: 8px;
|
||||
padding: 16px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.qr-title {
|
||||
margin: 0 0 12px 0;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.qr-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
/* Academia Info Card */
|
||||
.academia-info-card {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.academia-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.academia-titulo {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.academia-stats {
|
||||
margin: 16px 0;
|
||||
}
|
||||
|
||||
.stat-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 12px;
|
||||
color: #666;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.academia-status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.ya-vinculado {
|
||||
color: #52c41a;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
/* Éxito y Error */
|
||||
.exito-content,
|
||||
.error-content {
|
||||
text-align: center;
|
||||
padding: 24px 0;
|
||||
}
|
||||
|
||||
.exito-icon,
|
||||
.error-icon {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.exito-content h2,
|
||||
.error-content h2 {
|
||||
margin: 0 0 8px 0;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.exito-content p,
|
||||
.error-content p {
|
||||
margin: 0 0 24px 0;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.exito-details,
|
||||
.error-sugerencias {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.exito-acciones,
|
||||
.error-acciones {
|
||||
max-width: 400px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
/* Responsive */
|
||||
@media (max-width: 576px) {
|
||||
.vinculacion-modal {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.step-header {
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.step-number {
|
||||
align-self: center;
|
||||
}
|
||||
|
||||
.qr-actions {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.academia-header {
|
||||
flex-direction: column;
|
||||
text-align: center;
|
||||
gap: 12px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@ -1,307 +0,0 @@
|
||||
<!-- components/SuperAdmin/AcademiasList.vue -->
|
||||
<template>
|
||||
<div class="academias-list">
|
||||
<a-card title="Gestión de Academias" class="mb-4">
|
||||
<template #extra>
|
||||
<a-space>
|
||||
<a-input-search
|
||||
v-model:value="searchText"
|
||||
placeholder="Buscar academias..."
|
||||
style="width: 250px"
|
||||
@search="handleSearch"
|
||||
/>
|
||||
<a-button type="primary" @click="showModal = true">
|
||||
<template #icon><plus-outlined /></template>
|
||||
Nueva Academia
|
||||
</a-button>
|
||||
</a-space>
|
||||
</template>
|
||||
|
||||
<a-table
|
||||
:columns="columns"
|
||||
:data-source="academiaStore.getAcademias"
|
||||
:loading="academiaStore.isLoading"
|
||||
:pagination="academiaStore.getPagination"
|
||||
@change="handleTableChange"
|
||||
>
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.key === 'codigo'">
|
||||
<a-tag color="blue">{{ record.codigo }}</a-tag>
|
||||
</template>
|
||||
|
||||
<template v-if="column.key === 'estado'">
|
||||
<a-tag :color="record.activa ? 'green' : 'red'">
|
||||
{{ record.activa ? 'Activa' : 'Inactiva' }}
|
||||
</a-tag>
|
||||
</template>
|
||||
|
||||
<template v-if="column.key === 'estudiantes'">
|
||||
<span>{{ record.estudiantes_activos }} / {{ record.limite_estudiantes }}</span>
|
||||
</template>
|
||||
|
||||
<template v-if="column.key === 'admin'">
|
||||
<span>{{ record.admin_academia?.name || 'No asignado' }}</span>
|
||||
</template>
|
||||
|
||||
<template v-if="column.key === 'acciones'">
|
||||
<a-space>
|
||||
<a-button type="link" size="small" @click="verDetalles(record)">
|
||||
<eye-outlined />
|
||||
</a-button>
|
||||
<a-button type="link" size="small" @click="editarAcademia(record)">
|
||||
<edit-outlined />
|
||||
</a-button>
|
||||
<a-popconfirm
|
||||
title="¿Estás seguro de eliminar esta academia?"
|
||||
@confirm="eliminarAcademia(record.id)"
|
||||
>
|
||||
<a-button type="link" danger size="small">
|
||||
<delete-outlined />
|
||||
</a-button>
|
||||
</a-popconfirm>
|
||||
</a-space>
|
||||
</template>
|
||||
</template>
|
||||
</a-table>
|
||||
</a-card>
|
||||
|
||||
<!-- Modal para crear/editar academia -->
|
||||
<a-modal
|
||||
v-model:open="showModal"
|
||||
:title="modalTitle"
|
||||
width="600px"
|
||||
@ok="handleModalOk"
|
||||
@cancel="handleModalCancel"
|
||||
>
|
||||
<a-form
|
||||
ref="formRef"
|
||||
:model="formState"
|
||||
:rules="rules"
|
||||
layout="vertical"
|
||||
@finish="handleSubmit"
|
||||
>
|
||||
<a-form-item label="Nombre" name="nombre">
|
||||
<a-input
|
||||
v-model:value="formState.nombre"
|
||||
placeholder="Nombre de la academia"
|
||||
/>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item label="Descripción" name="descripcion">
|
||||
<a-textarea
|
||||
v-model:value="formState.descripcion"
|
||||
placeholder="Descripción de la academia"
|
||||
:rows="3"
|
||||
/>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item label="Límite de Estudiantes" name="limite_estudiantes">
|
||||
<a-input-number
|
||||
v-model:value="formState.limite_estudiantes"
|
||||
:min="1"
|
||||
:max="10000"
|
||||
style="width: 100%"
|
||||
/>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item label="Administrador" name="admin_academia_id" required>
|
||||
<a-select
|
||||
v-model:value="formState.admin_academia_id"
|
||||
placeholder="Seleccionar administrador"
|
||||
:loading="loadingAdmins"
|
||||
show-search
|
||||
option-filter-prop="label"
|
||||
>
|
||||
<a-select-option
|
||||
v-for="admin in administradores"
|
||||
:key="admin.id"
|
||||
:value="admin.id"
|
||||
:label="admin.name"
|
||||
>
|
||||
{{ admin.name }} ({{ admin.email }})
|
||||
</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item label="Estado" name="activa">
|
||||
<a-switch
|
||||
v-model:checked="formState.activa"
|
||||
checked-children="Activa"
|
||||
un-checked-children="Inactiva"
|
||||
/>
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
</a-modal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, computed, onMounted } from 'vue'
|
||||
import { message } from 'ant-design-vue'
|
||||
import {
|
||||
PlusOutlined,
|
||||
EyeOutlined,
|
||||
EditOutlined,
|
||||
DeleteOutlined
|
||||
} from '@ant-design/icons-vue'
|
||||
import { useAcademiaStore } from '../../stores/academia'
|
||||
import { useUserStore } from '../../stores/user'
|
||||
|
||||
const academiaStore = useAcademiaStore()
|
||||
const userStore = useUserStore()
|
||||
|
||||
const searchText = ref('')
|
||||
const showModal = ref(false)
|
||||
const formRef = ref()
|
||||
const loadingAdmins = ref(false)
|
||||
const administradores = ref([])
|
||||
const editingId = ref(null)
|
||||
|
||||
const columns = [
|
||||
{
|
||||
title: 'Código',
|
||||
dataIndex: 'codigo',
|
||||
key: 'codigo',
|
||||
width: 150
|
||||
},
|
||||
{
|
||||
title: 'Nombre',
|
||||
dataIndex: 'nombre',
|
||||
key: 'nombre',
|
||||
ellipsis: true
|
||||
},
|
||||
{
|
||||
title: 'Estado',
|
||||
key: 'estado',
|
||||
width: 100
|
||||
},
|
||||
{
|
||||
title: 'Estudiantes',
|
||||
key: 'estudiantes',
|
||||
width: 150
|
||||
},
|
||||
{
|
||||
title: 'Administrador',
|
||||
key: 'admin',
|
||||
ellipsis: true
|
||||
},
|
||||
{
|
||||
title: 'Creada',
|
||||
dataIndex: 'created_at',
|
||||
key: 'created_at',
|
||||
width: 150,
|
||||
customRender: ({ text }) => new Date(text).toLocaleDateString()
|
||||
},
|
||||
{
|
||||
title: 'Acciones',
|
||||
key: 'acciones',
|
||||
width: 150
|
||||
}
|
||||
]
|
||||
|
||||
const formState = reactive({
|
||||
nombre: '',
|
||||
descripcion: '',
|
||||
limite_estudiantes: 100,
|
||||
admin_academia_id: null,
|
||||
activa: true
|
||||
})
|
||||
|
||||
const rules = {
|
||||
nombre: [
|
||||
{ required: true, message: 'Por favor ingresa el nombre' },
|
||||
{ max: 255, message: 'Máximo 255 caracteres' }
|
||||
],
|
||||
admin_academia_id: [
|
||||
{ required: true, message: 'Por favor selecciona un administrador' }
|
||||
]
|
||||
}
|
||||
|
||||
const modalTitle = computed(() =>
|
||||
editingId.value ? 'Editar Academia' : 'Nueva Academia'
|
||||
)
|
||||
|
||||
onMounted(() => {
|
||||
academiaStore.fetchAcademias()
|
||||
cargarAdministradores()
|
||||
})
|
||||
|
||||
const cargarAdministradores = async () => {
|
||||
try {
|
||||
loadingAdmins.value = true
|
||||
// Aquí deberías implementar un endpoint para obtener usuarios con rol administrador
|
||||
const response = await api.get('/usuarios?role=administrador')
|
||||
administradores.value = response.data
|
||||
} catch (error) {
|
||||
message.error('Error al cargar administradores')
|
||||
} finally {
|
||||
loadingAdmins.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleSearch = () => {
|
||||
academiaStore.setFiltros({ search: searchText.value })
|
||||
}
|
||||
|
||||
const handleTableChange = (pagination) => {
|
||||
academiaStore.setPagination({
|
||||
current: pagination.current,
|
||||
pageSize: pagination.pageSize
|
||||
})
|
||||
}
|
||||
|
||||
const verDetalles = (academia) => {
|
||||
// Navegar a la vista de detalles
|
||||
router.push(`/admin/academias/${academia.id}`)
|
||||
}
|
||||
|
||||
const editarAcademia = (academia) => {
|
||||
editingId.value = academia.id
|
||||
Object.assign(formState, {
|
||||
nombre: academia.nombre,
|
||||
descripcion: academia.descripcion,
|
||||
limite_estudiantes: academia.limite_estudiantes,
|
||||
admin_academia_id: academia.admin_academia_id,
|
||||
activa: academia.activa
|
||||
})
|
||||
showModal.value = true
|
||||
}
|
||||
|
||||
const eliminarAcademia = async (id) => {
|
||||
await academiaStore.deleteAcademia(id)
|
||||
}
|
||||
|
||||
const handleModalOk = () => {
|
||||
formRef.value.validateFields().then(async () => {
|
||||
if (editingId.value) {
|
||||
await academiaStore.updateAcademia(editingId.value, formState)
|
||||
} else {
|
||||
await academiaStore.createAcademia(formState)
|
||||
}
|
||||
showModal.value = false
|
||||
resetForm()
|
||||
})
|
||||
}
|
||||
|
||||
const handleModalCancel = () => {
|
||||
resetForm()
|
||||
}
|
||||
|
||||
const resetForm = () => {
|
||||
formRef.value?.resetFields()
|
||||
editingId.value = null
|
||||
Object.assign(formState, {
|
||||
nombre: '',
|
||||
descripcion: '',
|
||||
limite_estudiantes: 100,
|
||||
admin_academia_id: null,
|
||||
activa: true
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.academias-list {
|
||||
padding: 20px;
|
||||
}
|
||||
</style>
|
||||
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,658 @@
|
||||
<template>
|
||||
<a-layout-header class="modern-header">
|
||||
<div class="header-container">
|
||||
<!-- LOGO -->
|
||||
<div
|
||||
class="university-logo"
|
||||
@click="handleLogoClick"
|
||||
style="cursor: pointer;"
|
||||
>
|
||||
<div class="logo-icon">
|
||||
<img src="/logotiny.png" alt="Logo UNA" />
|
||||
</div>
|
||||
<div class="logo-text">
|
||||
<h1>Universidad Nacional del Altiplano</h1>
|
||||
<span>Dirección de Admisión</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- DESKTOP NAV -->
|
||||
<nav class="modern-nav desktop-only">
|
||||
<a-menu
|
||||
v-model:selectedKeys="selectedKeys"
|
||||
mode="horizontal"
|
||||
class="nav-menu-modern"
|
||||
:items="navItems"
|
||||
@click="handleMenuClick"
|
||||
@openChange="handleDesktopOpenChange"
|
||||
/>
|
||||
</nav>
|
||||
|
||||
<!-- MOBILE MENU BUTTON -->
|
||||
<a-button
|
||||
class="mobile-menu-btn mobile-only"
|
||||
type="text"
|
||||
@click="drawerOpen = true"
|
||||
>
|
||||
☰
|
||||
</a-button>
|
||||
</div>
|
||||
|
||||
<!-- MOBILE DRAWER -->
|
||||
<a-drawer
|
||||
title="Menú"
|
||||
placement="right"
|
||||
:open="drawerOpen && isMobile"
|
||||
@close="handleDrawerClose"
|
||||
:width="280"
|
||||
:bodyStyle="{ padding: 0 }"
|
||||
:headerStyle="{ borderBottom: '1px solid #f0f0f0', fontFamily: 'Times New Roman' }"
|
||||
:closable="true"
|
||||
:maskClosable="true"
|
||||
>
|
||||
<div class="drawer-content">
|
||||
<div class="drawer-header">
|
||||
<div class="drawer-logo">
|
||||
<div class="logo-icon">
|
||||
<img src="/logotiny.png" alt="Logo UNA" />
|
||||
</div>
|
||||
<div class="drawer-logo-text">
|
||||
<h3>UNA</h3>
|
||||
<span>Dirección de Admisión</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<a-menu
|
||||
v-model:selectedKeys="selectedKeys"
|
||||
mode="inline"
|
||||
:items="mobileNavItems"
|
||||
class="drawer-menu"
|
||||
@click="handleDrawerMenuClick"
|
||||
@openChange="handleMobileOpenChange"
|
||||
:openKeys="mobileOpenKeys"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="auth-section">
|
||||
<router-link
|
||||
v-if="!authStore.isAuthenticated"
|
||||
to="/login-postulante"
|
||||
class="login-link"
|
||||
>
|
||||
<a-button type="primary">
|
||||
<template #icon><UserOutlined /></template>
|
||||
Portal del Postulante
|
||||
</a-button>
|
||||
</router-link>
|
||||
|
||||
<router-link
|
||||
v-else
|
||||
to="/portal"
|
||||
class="portal-link"
|
||||
>
|
||||
<a-button type="primary" ghost>
|
||||
<template #icon><DashboardOutlined /></template>
|
||||
Mi Portal
|
||||
</a-button>
|
||||
</router-link>
|
||||
</div>
|
||||
</a-drawer>
|
||||
|
||||
<div class="auth-section">
|
||||
<router-link
|
||||
v-if="!authStore.isAuthenticated"
|
||||
to="/login-postulante"
|
||||
class="login-link"
|
||||
>
|
||||
<a-button type="primary">
|
||||
<template #icon><UserOutlined /></template>
|
||||
Portal del Postulante
|
||||
</a-button>
|
||||
</router-link>
|
||||
|
||||
<router-link
|
||||
v-else
|
||||
to="/portal-postulante"
|
||||
class="portal-link"
|
||||
>
|
||||
<a-button type="primary" ghost>
|
||||
<template #icon><DashboardOutlined /></template>
|
||||
Mi Portal
|
||||
</a-button>
|
||||
</router-link>
|
||||
</div>
|
||||
</a-layout-header>
|
||||
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, watch, onMounted, onUnmounted } from 'vue'
|
||||
import { useAuthStore } from '../store/postulanteStore'
|
||||
import { UserOutlined, DashboardOutlined } from '@ant-design/icons-vue'
|
||||
const drawerOpen = ref(false)
|
||||
const selectedKeys = ref(['inicio'])
|
||||
const isMobile = ref(false)
|
||||
|
||||
|
||||
const authStore = useAuthStore()
|
||||
|
||||
const desktopOpenKeys = ref([])
|
||||
const mobileOpenKeys = ref([])
|
||||
|
||||
// Detectar tamaño de pantalla
|
||||
const checkScreenSize = () => {
|
||||
isMobile.value = window.innerWidth < 768
|
||||
|
||||
// Si cambia a desktop, cerrar el drawer
|
||||
if (!isMobile.value && drawerOpen.value) {
|
||||
drawerOpen.value = false
|
||||
mobileOpenKeys.value = [] // Limpiar openKeys del móvil
|
||||
}
|
||||
}
|
||||
|
||||
// Items del menú para desktop
|
||||
const navItems = computed(() => [
|
||||
{
|
||||
key: 'inicio',
|
||||
label: 'Inicio'
|
||||
},
|
||||
{
|
||||
key: 'programas',
|
||||
label: 'Programas',
|
||||
children: [
|
||||
{ key: 'ingenierias', label: 'Ingenierías' },
|
||||
{ key: 'biomedicas', label: 'Biomédicas' },
|
||||
{ key: 'sociales', label: 'Sociales' }
|
||||
]
|
||||
},
|
||||
{
|
||||
key: 'procesos',
|
||||
label: 'Procesos'
|
||||
},
|
||||
{
|
||||
key: 'modalidades',
|
||||
label: 'Modalidades',
|
||||
children: [
|
||||
{ key: 'ordinario', label: 'Ordinario' },
|
||||
{ key: 'extraordinario', label: 'Extraordinario' },
|
||||
{ key: 'sedes', label: 'Sedes' }
|
||||
]
|
||||
},
|
||||
{
|
||||
key: 'resultados',
|
||||
label: 'Resultados'
|
||||
}
|
||||
])
|
||||
|
||||
// Items del menú para móvil con submenús contraídos
|
||||
const mobileNavItems = computed(() => {
|
||||
return navItems.value.map(item => {
|
||||
const baseItem = {
|
||||
key: item.key,
|
||||
label: item.label
|
||||
}
|
||||
|
||||
if (item.children) {
|
||||
// Submenú plegable en móvil
|
||||
return {
|
||||
...baseItem,
|
||||
children: item.children.map(child => ({
|
||||
key: child.key,
|
||||
label: child.label
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
return baseItem
|
||||
})
|
||||
})
|
||||
|
||||
// Observar cambios en el drawer para limpiar openKeys cuando se cierra
|
||||
watch(drawerOpen, (newVal) => {
|
||||
if (!newVal) {
|
||||
// Limpiar openKeys cuando se cierra el drawer
|
||||
mobileOpenKeys.value = []
|
||||
}
|
||||
})
|
||||
|
||||
// Observar cambios en isMobile para cerrar drawer si cambia a desktop
|
||||
watch(isMobile, (newVal) => {
|
||||
if (!newVal && drawerOpen.value) {
|
||||
drawerOpen.value = false
|
||||
}
|
||||
})
|
||||
|
||||
const handleMenuClick = ({ key }) => {
|
||||
selectedKeys.value = [key]
|
||||
|
||||
console.log('Navegando a:', key)
|
||||
|
||||
// Si es un item de submenú, cerrar el drawer en móvil
|
||||
const isSubmenuItem = navItems.value.some(item =>
|
||||
item.children && item.children.some(child => child.key === key)
|
||||
)
|
||||
|
||||
if (isSubmenuItem && isMobile.value) {
|
||||
drawerOpen.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleDrawerMenuClick = ({ key }) => {
|
||||
handleMenuClick({ key })
|
||||
|
||||
// Si NO es un submenú padre, cerrar el drawer
|
||||
const isParentItem = navItems.value.some(item =>
|
||||
item.key === key && item.children
|
||||
)
|
||||
|
||||
if (!isParentItem && isMobile.value) {
|
||||
drawerOpen.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleLogoClick = () => {
|
||||
selectedKeys.value = ['inicio']
|
||||
// router.push('/')
|
||||
}
|
||||
|
||||
const handleDesktopOpenChange = (keys) => {
|
||||
desktopOpenKeys.value = keys
|
||||
}
|
||||
|
||||
const handleMobileOpenChange = (keys) => {
|
||||
// En móvil, solo permitir un submenú abierto a la vez
|
||||
const latestOpenKey = keys.find(key => !mobileOpenKeys.value.includes(key))
|
||||
|
||||
if (latestOpenKey) {
|
||||
mobileOpenKeys.value = [latestOpenKey]
|
||||
} else {
|
||||
mobileOpenKeys.value = []
|
||||
}
|
||||
}
|
||||
|
||||
const handleDrawerClose = () => {
|
||||
// Limpiar openKeys y cerrar drawer
|
||||
mobileOpenKeys.value = []
|
||||
drawerOpen.value = false
|
||||
}
|
||||
|
||||
// Configurar event listeners para responsive
|
||||
onMounted(() => {
|
||||
checkScreenSize()
|
||||
window.addEventListener('resize', checkScreenSize)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('resize', checkScreenSize)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* VARIABLES */
|
||||
:root {
|
||||
--primary-color: #1e3a8a;
|
||||
--secondary-color: #374151;
|
||||
--border-color: #d1d5db;
|
||||
--bg-color: #ffffff;
|
||||
}
|
||||
|
||||
/* FUENTE INSTITUCIONAL */
|
||||
.modern-header,
|
||||
.nav-menu-modern,
|
||||
.logo-text h1,
|
||||
.logo-text span,
|
||||
.drawer-logo-text h3,
|
||||
.drawer-logo-text span {
|
||||
font-family: "Times New Roman", Times, serif;
|
||||
}
|
||||
|
||||
/* HEADER */
|
||||
.modern-header {
|
||||
background: rgba(255, 255, 255, 0.92) !important;
|
||||
backdrop-filter: blur(10px);
|
||||
-webkit-backdrop-filter: blur(10px);
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
padding: 0 24px !important;
|
||||
height: 82px !important;
|
||||
line-height: 82px !important;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 1000;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
/* CONTAINER */
|
||||
.header-container {
|
||||
max-width: 1320px;
|
||||
margin: 0 auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* LOGO */
|
||||
.university-logo {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
min-width: 250px;
|
||||
transition: opacity 0.3s;
|
||||
}
|
||||
|
||||
.university-logo:hover {
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.logo-icon {
|
||||
width: 46px;
|
||||
height: 46px;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 6px;
|
||||
background: var(--bg-color);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.logo-icon img {
|
||||
width: 90%;
|
||||
height: 90%;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.logo-text {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.logo-text h1 {
|
||||
margin: 0;
|
||||
font-size: 1.05rem;
|
||||
font-weight: 700;
|
||||
color: var(--primary-color);
|
||||
}
|
||||
|
||||
.logo-text span {
|
||||
font-size: 0.8rem;
|
||||
color: var(--secondary-color);
|
||||
}
|
||||
|
||||
/* NAV DESKTOP */
|
||||
.modern-nav {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.nav-menu-modern {
|
||||
flex: 1;
|
||||
background: transparent !important;
|
||||
border-bottom: none !important;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.nav-menu-modern :deep(.ant-menu-item),
|
||||
.nav-menu-modern :deep(.ant-menu-submenu) {
|
||||
height: 82px;
|
||||
line-height: 82px;
|
||||
padding: 0 16px;
|
||||
font-size: 0.95rem;
|
||||
color: var(--secondary-color);
|
||||
transition: all 0.3s cubic-bezier(0.645, 0.045, 0.355, 1);
|
||||
}
|
||||
|
||||
.nav-menu-modern :deep(.ant-menu-item:hover),
|
||||
.nav-menu-modern :deep(.ant-menu-submenu:hover) {
|
||||
color: var(--primary-color);
|
||||
}
|
||||
|
||||
.nav-menu-modern :deep(.ant-menu-item-selected),
|
||||
.nav-menu-modern :deep(.ant-menu-submenu-selected) {
|
||||
color: var(--primary-color);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.nav-menu-modern :deep(.ant-menu-item-selected::after),
|
||||
.nav-menu-modern :deep(.ant-menu-submenu-selected::after) {
|
||||
border-bottom: 3px solid var(--primary-color) !important;
|
||||
}
|
||||
|
||||
.nav-menu-modern :deep(.ant-menu-submenu-arrow) {
|
||||
color: var(--secondary-color);
|
||||
}
|
||||
|
||||
.nav-menu-modern :deep(.ant-menu-submenu:hover .ant-menu-submenu-arrow) {
|
||||
color: var(--primary-color);
|
||||
}
|
||||
|
||||
/* BOTÓN MÓVIL */
|
||||
.mobile-menu-btn {
|
||||
font-size: 24px;
|
||||
color: #111827;
|
||||
padding: 8px;
|
||||
border-radius: 4px;
|
||||
transition: background-color 0.3s;
|
||||
}
|
||||
|
||||
.mobile-menu-btn:hover {
|
||||
background: rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
|
||||
/* DRAWER MÓVIL */
|
||||
.drawer-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.drawer-header {
|
||||
padding: 20px 24px;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
background: #fafafa;
|
||||
}
|
||||
|
||||
.drawer-logo {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.drawer-logo-text {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.drawer-logo-text h3 {
|
||||
margin: 0;
|
||||
color: var(--primary-color);
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.drawer-logo-text span {
|
||||
font-size: 12px;
|
||||
color: var(--secondary-color);
|
||||
}
|
||||
|
||||
.drawer-menu {
|
||||
border-right: none !important;
|
||||
}
|
||||
|
||||
.drawer-menu :deep(.ant-menu-item) {
|
||||
font-size: 15px;
|
||||
padding-left: 24px !important;
|
||||
height: 48px;
|
||||
line-height: 48px;
|
||||
margin: 4px 0;
|
||||
border-radius: 0;
|
||||
transition: background-color 0.3s;
|
||||
}
|
||||
|
||||
.drawer-menu :deep(.ant-menu-item:hover) {
|
||||
background-color: #f0f7ff;
|
||||
}
|
||||
|
||||
.drawer-menu :deep(.ant-menu-item-selected) {
|
||||
background-color: #f0f7ff;
|
||||
color: var(--primary-color);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.drawer-menu :deep(.ant-menu-item-selected::after) {
|
||||
border-right: 3px solid var(--primary-color);
|
||||
}
|
||||
|
||||
.drawer-menu :deep(.ant-menu-submenu-title) {
|
||||
font-size: 15px;
|
||||
padding-left: 24px !important;
|
||||
height: 48px;
|
||||
line-height: 48px;
|
||||
margin: 4px 0;
|
||||
border-radius: 0;
|
||||
transition: background-color 0.3s;
|
||||
}
|
||||
|
||||
.drawer-menu :deep(.ant-menu-submenu-title:hover) {
|
||||
background-color: #f0f7ff;
|
||||
}
|
||||
|
||||
.drawer-menu :deep(.ant-menu-submenu-selected > .ant-menu-submenu-title) {
|
||||
color: var(--primary-color);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.drawer-menu :deep(.ant-menu-submenu-arrow) {
|
||||
font-size: 12px !important;
|
||||
}
|
||||
|
||||
.drawer-menu :deep(.ant-menu-submenu .ant-menu-item) {
|
||||
padding-left: 48px !important;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
/* RESPONSIVE */
|
||||
.desktop-only {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.mobile-only {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* TABLET (768px - 992px) */
|
||||
@media (max-width: 992px) {
|
||||
.modern-header {
|
||||
padding: 0 16px !important;
|
||||
}
|
||||
|
||||
.header-container {
|
||||
padding: 0 8px;
|
||||
}
|
||||
|
||||
.logo-text h1 {
|
||||
font-size: 14px !important;
|
||||
}
|
||||
|
||||
.logo-text span {
|
||||
font-size: 12px !important;
|
||||
}
|
||||
|
||||
.nav-menu-modern :deep(.ant-menu-item),
|
||||
.nav-menu-modern :deep(.ant-menu-submenu) {
|
||||
padding: 0 12px;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* MOBILE (< 768px) */
|
||||
@media (max-width: 768px) {
|
||||
.desktop-only {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.mobile-only {
|
||||
display: inline-flex;
|
||||
}
|
||||
|
||||
.modern-header {
|
||||
height: 72px !important;
|
||||
line-height: 72px !important;
|
||||
}
|
||||
|
||||
.header-container {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.university-logo {
|
||||
min-width: auto;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.logo-icon {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
}
|
||||
|
||||
.logo-text h1 {
|
||||
font-size: 13px !important;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.logo-text span {
|
||||
font-size: 11px !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* SMALL MOBILE (< 480px) */
|
||||
@media (max-width: 480px) {
|
||||
.modern-header {
|
||||
padding: 0 12px !important;
|
||||
}
|
||||
|
||||
.logo-text h1 {
|
||||
font-size: 12px !important;
|
||||
}
|
||||
|
||||
.logo-text span {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.mobile-menu-btn {
|
||||
font-size: 20px;
|
||||
padding: 4px;
|
||||
}
|
||||
|
||||
.drawer-menu :deep(.ant-menu-submenu-title),
|
||||
.drawer-menu :deep(.ant-menu-item) {
|
||||
height: 44px;
|
||||
line-height: 44px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.drawer-menu :deep(.ant-menu-submenu .ant-menu-item) {
|
||||
height: 40px;
|
||||
line-height: 40px;
|
||||
font-size: 13px;
|
||||
}
|
||||
}
|
||||
|
||||
/* IMPRESIÓN */
|
||||
@media print {
|
||||
.modern-header {
|
||||
position: static;
|
||||
box-shadow: none;
|
||||
border-bottom: 2px solid #000;
|
||||
}
|
||||
|
||||
.mobile-menu-btn {
|
||||
display: none !important;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@ -1,74 +0,0 @@
|
||||
// stores/areasStore.js
|
||||
import { defineStore } from 'pinia';
|
||||
import axios from 'axios';
|
||||
|
||||
export const useAreasStore = defineStore('areas', {
|
||||
state: () => ({
|
||||
cursosDisponibles: [], // todos los cursos
|
||||
cursosVinculados: [], // cursos vinculados a la área
|
||||
loading: false,
|
||||
error: null,
|
||||
}),
|
||||
|
||||
actions: {
|
||||
// Obtener cursos de un área
|
||||
async fetchCursosPorArea(areaId) {
|
||||
this.loading = true;
|
||||
this.error = null;
|
||||
try {
|
||||
const response = await axios.get(`/areas/${areaId}/cursos-disponibles`);
|
||||
if (response.data.success) {
|
||||
this.cursosDisponibles = response.data.data.todos_los_cursos;
|
||||
this.cursosVinculados = response.data.data.cursos_vinculados;
|
||||
} else {
|
||||
this.error = 'Error cargando cursos';
|
||||
}
|
||||
} catch (err) {
|
||||
this.error = err.response?.data?.message || err.message;
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
},
|
||||
|
||||
// Vincular cursos a un área
|
||||
async vincularCursos(areaId, cursosIds) {
|
||||
this.loading = true;
|
||||
this.error = null;
|
||||
try {
|
||||
const response = await axios.post(`/areas/${areaId}/vincular-cursos`, {
|
||||
cursos: cursosIds
|
||||
});
|
||||
if (response.data.success) {
|
||||
this.cursosVinculados = cursosIds;
|
||||
} else {
|
||||
this.error = 'Error vinculando cursos';
|
||||
}
|
||||
} catch (err) {
|
||||
this.error = err.response?.data?.message || err.message;
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
},
|
||||
|
||||
// Desvincular un curso de un área
|
||||
async desvincularCurso(areaId, cursoId) {
|
||||
this.loading = true;
|
||||
this.error = null;
|
||||
try {
|
||||
const response = await axios.post(`/areas/${areaId}/desvincular-curso`, {
|
||||
curso_id: cursoId
|
||||
});
|
||||
if (response.data.success) {
|
||||
// Actualizar lista local
|
||||
this.cursosVinculados = this.cursosVinculados.filter(id => id !== cursoId);
|
||||
} else {
|
||||
this.error = 'Error desvinculando curso';
|
||||
}
|
||||
} catch (err) {
|
||||
this.error = err.response?.data?.message || err.message;
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
@ -0,0 +1,139 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import api from '../axiosPostulante'
|
||||
|
||||
|
||||
export const useExamenStore = defineStore('examenStore', {
|
||||
state: () => ({
|
||||
procesos: [],
|
||||
areas: [],
|
||||
examenActual: null,
|
||||
preguntas: [],
|
||||
cargando: false,
|
||||
error: null,
|
||||
}),
|
||||
actions: {
|
||||
// 1. Obtener procesos disponibles para el postulante
|
||||
async fetchProcesos() {
|
||||
try {
|
||||
this.cargando = true
|
||||
const { data } = await api.get('/examen/procesos')
|
||||
this.procesos = data
|
||||
} catch (e) {
|
||||
this.error = e.response?.data?.message || e.message
|
||||
} finally {
|
||||
this.cargando = false
|
||||
}
|
||||
},
|
||||
|
||||
// 2. Obtener áreas por proceso
|
||||
async fetchAreas(proceso_id) {
|
||||
try {
|
||||
this.cargando = true
|
||||
const { data } = await api.get('/examen/areas', {
|
||||
params: { proceso_id }
|
||||
})
|
||||
this.areas = data
|
||||
} catch (e) {
|
||||
this.error = e.response?.data?.message || e.message
|
||||
} finally {
|
||||
this.cargando = false
|
||||
}
|
||||
},
|
||||
|
||||
// 3. Crear examen (sin preguntas)
|
||||
async crearExamen(area_proceso_id, pago = null) {
|
||||
try {
|
||||
this.cargando = true
|
||||
const payload = { area_proceso_id, ...pago }
|
||||
const { data } = await api.post('/examen/crear', payload)
|
||||
this.examenActual = { id: data.examen_id }
|
||||
return data
|
||||
} catch (e) {
|
||||
this.error = e.response?.data?.message || e.message
|
||||
return { success: false, message: this.error }
|
||||
} finally {
|
||||
this.cargando = false
|
||||
}
|
||||
},
|
||||
|
||||
// 4. Obtener examen actual
|
||||
async fetchExamenActual() {
|
||||
try {
|
||||
this.cargando = true
|
||||
const { data } = await api.get('/examen/actual')
|
||||
this.examenActual = data.examen
|
||||
} catch (e) {
|
||||
this.error = e.response?.data?.message || e.message
|
||||
} finally {
|
||||
this.cargando = false
|
||||
}
|
||||
},
|
||||
|
||||
// 5. Generar preguntas para un examen
|
||||
async generarPreguntas(examenId) {
|
||||
try {
|
||||
this.cargando = true
|
||||
const { data } = await api.post(`/examen/${examenId}/generar-preguntas`)
|
||||
return data
|
||||
} catch (e) {
|
||||
this.error = e.response?.data?.message || e.message
|
||||
return { success: false, message: this.error }
|
||||
} finally {
|
||||
this.cargando = false
|
||||
}
|
||||
},
|
||||
|
||||
// 6. Iniciar examen
|
||||
async iniciarExamen(examenId) {
|
||||
try {
|
||||
this.cargando = true
|
||||
const { data } = await api.post('/examen/iniciar', { examen_id: examenId })
|
||||
this.examenActual = data.examen
|
||||
this.preguntas = data.preguntas
|
||||
return data
|
||||
} catch (e) {
|
||||
this.error = e.response?.data?.message || e.message
|
||||
return { success: false, message: this.error }
|
||||
} finally {
|
||||
this.cargando = false
|
||||
}
|
||||
},
|
||||
|
||||
// 7. Responder pregunta
|
||||
async responderPregunta(preguntaId, respuesta) {
|
||||
try {
|
||||
const { data } = await api.post(`/examen/pregunta/${preguntaId}/responder`, { respuesta })
|
||||
// Actualizar pregunta local si es necesario
|
||||
const index = this.preguntas.findIndex(p => p.id === preguntaId)
|
||||
if (index !== -1) this.preguntas[index].respuesta = respuesta
|
||||
return data
|
||||
} catch (e) {
|
||||
this.error = e.response?.data?.message || e.message
|
||||
return { success: false, message: this.error }
|
||||
}
|
||||
},
|
||||
|
||||
// 8. Finalizar examen
|
||||
async finalizarExamen(examenId) {
|
||||
try {
|
||||
const { data } = await api.post(`/examen/${examenId}/finalizar`)
|
||||
this.examenActual = null
|
||||
this.preguntas = []
|
||||
return data
|
||||
} catch (e) {
|
||||
this.error = e.response?.data?.message || e.message
|
||||
return { success: false, message: this.error }
|
||||
}
|
||||
},
|
||||
|
||||
// Limpiar estado
|
||||
resetStore() {
|
||||
this.procesos = []
|
||||
this.areas = []
|
||||
this.examenActual = null
|
||||
this.preguntas = []
|
||||
this.cargando = false
|
||||
this.error = null
|
||||
}
|
||||
}
|
||||
})
|
||||
@ -0,0 +1,141 @@
|
||||
// stores/authStore.js
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref, computed } from 'vue'
|
||||
import api from '../axiosPostulante'
|
||||
|
||||
export const useAuthStore = defineStore('auth', () => {
|
||||
// Estado
|
||||
const token = ref(localStorage.getItem('postulante_token') || null)
|
||||
const postulante = ref(JSON.parse(localStorage.getItem('postulante_data') || 'null'))
|
||||
const loading = ref(false)
|
||||
const error = ref(null)
|
||||
|
||||
// Getters
|
||||
const isAuthenticated = computed(() => !!token.value)
|
||||
const userDni = computed(() => postulante.value?.dni || null)
|
||||
const userEmail = computed(() => postulante.value?.email || null)
|
||||
const userName = computed(() => postulante.value?.name || null)
|
||||
const userId = computed(() => postulante.value?.id || null)
|
||||
|
||||
// Actions
|
||||
|
||||
// Registro
|
||||
const register = async ({ name, email, password, password_confirmation, dni }) => {
|
||||
try {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
|
||||
const response = await api.post('/postulante/register', {
|
||||
name,
|
||||
email,
|
||||
password,
|
||||
password_confirmation,
|
||||
dni
|
||||
})
|
||||
|
||||
if (response.data.success) {
|
||||
token.value = response.data.token
|
||||
postulante.value = response.data.postulante
|
||||
|
||||
localStorage.setItem('postulante_token', token.value)
|
||||
localStorage.setItem('postulante_data', JSON.stringify(postulante.value))
|
||||
|
||||
return { success: true }
|
||||
}
|
||||
|
||||
return { success: false, error: response.data.message || 'Error en registro' }
|
||||
} catch (err) {
|
||||
error.value = err.response?.data?.message || 'Error en registro'
|
||||
return { success: false, error: error.value }
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// Login
|
||||
const login = async ({ email, password, device_id = null }) => {
|
||||
try {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
|
||||
const response = await api.post('/postulante/login', { email, password, device_id })
|
||||
|
||||
if (response.data.success) {
|
||||
token.value = response.data.token
|
||||
postulante.value = response.data.postulante
|
||||
|
||||
localStorage.setItem('postulante_token', token.value)
|
||||
localStorage.setItem('postulante_data', JSON.stringify(postulante.value))
|
||||
|
||||
return { success: true }
|
||||
}
|
||||
|
||||
return { success: false, error: response.data.message || 'Credenciales inválidas' }
|
||||
} catch (err) {
|
||||
error.value = err.response?.data?.message || 'Error en login'
|
||||
return { success: false, error: error.value }
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// Logout
|
||||
const logout = async () => {
|
||||
try {
|
||||
if (token.value) {
|
||||
await api.post('/postulante/logout', {}, {
|
||||
headers: { Authorization: `Bearer ${token.value}` }
|
||||
})
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error en logout:', err)
|
||||
} finally {
|
||||
token.value = null
|
||||
postulante.value = null
|
||||
localStorage.removeItem('postulante_token')
|
||||
localStorage.removeItem('postulante_data')
|
||||
}
|
||||
}
|
||||
|
||||
// Check sesión
|
||||
const checkAuth = async () => {
|
||||
if (!token.value) return false
|
||||
|
||||
try {
|
||||
const response = await api.get('/postulante/me', {
|
||||
headers: { Authorization: `Bearer ${token.value}` }
|
||||
})
|
||||
|
||||
if (response.data.success) {
|
||||
postulante.value = response.data.postulante
|
||||
return true
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error verificando autenticación:', err)
|
||||
await logout()
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
return {
|
||||
// Estado
|
||||
token,
|
||||
postulante,
|
||||
loading,
|
||||
error,
|
||||
|
||||
// Getters
|
||||
isAuthenticated,
|
||||
userDni,
|
||||
userEmail,
|
||||
userName,
|
||||
userId,
|
||||
|
||||
// Actions
|
||||
register,
|
||||
login,
|
||||
logout,
|
||||
checkAuth
|
||||
}
|
||||
})
|
||||
@ -0,0 +1,257 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import api from '../axios'
|
||||
|
||||
export const useReglaAreaProcesoStore = defineStore('reglaAreaProceso', {
|
||||
state: () => ({
|
||||
areaProcesoId: null,
|
||||
proceso: null,
|
||||
reglas: [],
|
||||
areaProcesos: [],
|
||||
cargando: false,
|
||||
guardando: false,
|
||||
error: null,
|
||||
totalPreguntasAsignadas: 0,
|
||||
preguntasDisponibles: 0,
|
||||
}),
|
||||
|
||||
getters: {
|
||||
// Obtener total de preguntas del proceso
|
||||
totalPreguntasProceso: (state) => state.proceso?.cantidad_total_preguntas || 0,
|
||||
|
||||
// Verificar si se ha alcanzado el límite
|
||||
limiteAlcanzado: (state) => state.totalPreguntasAsignadas >= (state.proceso?.cantidad_total_preguntas || 0),
|
||||
},
|
||||
|
||||
actions: {
|
||||
/**
|
||||
* Establecer el area_proceso actual y cargar sus reglas
|
||||
*/
|
||||
async setAreaProceso(id) {
|
||||
this.areaProcesoId = id
|
||||
await this.cargarReglas()
|
||||
},
|
||||
async cargarAreaProcesos() {
|
||||
this.cargando = true
|
||||
try {
|
||||
// Llamada al endpoint que devuelve áreas-proceso con nombres, reglas y cursos
|
||||
const { data } = await api.get('/area-proceso/areasprocesos')
|
||||
|
||||
// Guardamos en el store
|
||||
// Ahora contiene todas las áreas-proceso, con sus nombres y contadores
|
||||
this.areaProcesos = data.areaProcesos
|
||||
|
||||
} catch (err) {
|
||||
console.error(err)
|
||||
this.error = err.response?.data?.message || 'Error al cargar áreas-proceso'
|
||||
} finally {
|
||||
this.cargando = false
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
/**
|
||||
* Cargar reglas de un area_proceso
|
||||
*/
|
||||
async cargarReglas() {
|
||||
if (!this.areaProcesoId) return
|
||||
this.cargando = true
|
||||
this.error = null
|
||||
try {
|
||||
const { data } = await api.get(`/area-proceso/${this.areaProcesoId}/reglas`)
|
||||
this.proceso = data.proceso
|
||||
this.reglas = data.cursos
|
||||
this.totalPreguntasAsignadas = data.total_preguntas_asignadas || 0
|
||||
this.preguntasDisponibles = this.proceso.cantidad_total_preguntas - this.totalPreguntasAsignadas
|
||||
} catch (err) {
|
||||
console.error('Error al cargar reglas:', err)
|
||||
this.error = err.response?.data?.message || 'Error al cargar reglas'
|
||||
} finally {
|
||||
this.cargando = false
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Crear o actualizar una regla
|
||||
*/
|
||||
async guardarRegla(regla) {
|
||||
if (!this.areaProcesoId) return
|
||||
this.guardando = true
|
||||
this.error = null
|
||||
try {
|
||||
const { data } = await api.post(`/area-proceso/${this.areaProcesoId}/reglas`, regla)
|
||||
|
||||
// Actualizar la regla en el store si ya existe
|
||||
const index = this.reglas.findIndex(r => r.curso_id === data.regla.curso_id)
|
||||
if (index >= 0) {
|
||||
this.reglas[index] = { ...this.reglas[index], ...data.regla }
|
||||
} else {
|
||||
this.reglas.push(data.regla)
|
||||
}
|
||||
|
||||
// Ordenar por orden
|
||||
this.reglas.sort((a, b) => (a.orden || 999) - (b.orden || 999))
|
||||
|
||||
// Actualizar contadores
|
||||
this.totalPreguntasAsignadas = data.total_preguntas_asignadas
|
||||
this.preguntasDisponibles = data.preguntas_disponibles
|
||||
|
||||
return { success: true, data }
|
||||
} catch (err) {
|
||||
console.error('Error al guardar regla:', err)
|
||||
this.error = err.response?.data?.error || err.response?.data?.message || 'Error al guardar regla'
|
||||
return {
|
||||
success: false,
|
||||
error: this.error
|
||||
}
|
||||
} finally {
|
||||
this.guardando = false
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Guardar múltiples reglas a la vez
|
||||
*/
|
||||
async guardarReglasMultiple(reglas) {
|
||||
if (!this.areaProcesoId) return
|
||||
this.guardando = true
|
||||
this.error = null
|
||||
try {
|
||||
const { data } = await api.post(`/area-proceso/${this.areaProcesoId}/reglas/multiple`, {
|
||||
reglas: reglas
|
||||
})
|
||||
|
||||
// Recargar las reglas desde el servidor para asegurar consistencia
|
||||
await this.cargarReglas()
|
||||
|
||||
return { success: true, data }
|
||||
} catch (err) {
|
||||
console.error('Error al guardar reglas:', err)
|
||||
this.error = err.response?.data?.error || err.response?.data?.message || 'Error al guardar reglas'
|
||||
return {
|
||||
success: false,
|
||||
error: this.error
|
||||
}
|
||||
} finally {
|
||||
this.guardando = false
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Editar una regla existente
|
||||
*/
|
||||
async editarRegla(reglaId, cambios) {
|
||||
this.guardando = true
|
||||
this.error = null
|
||||
try {
|
||||
const { data } = await api.put(`/reglas/${reglaId}`, cambios)
|
||||
|
||||
// Actualizar store
|
||||
const index = this.reglas.findIndex(r => r.regla_id === reglaId)
|
||||
if (index >= 0) {
|
||||
this.reglas[index] = { ...this.reglas[index], ...data.regla }
|
||||
}
|
||||
|
||||
// Actualizar contadores
|
||||
if (data.total_preguntas_asignadas !== undefined) {
|
||||
this.totalPreguntasAsignadas = data.total_preguntas_asignadas
|
||||
this.preguntasDisponibles = this.proceso.cantidad_total_preguntas - this.totalPreguntasAsignadas
|
||||
}
|
||||
|
||||
// Ordenar por orden
|
||||
this.reglas.sort((a, b) => (a.orden || 999) - (b.orden || 999))
|
||||
|
||||
return { success: true, data }
|
||||
} catch (err) {
|
||||
console.error('Error al editar regla:', err)
|
||||
this.error = err.response?.data?.error || err.response?.data?.message || 'Error al editar regla'
|
||||
return {
|
||||
success: false,
|
||||
error: this.error
|
||||
}
|
||||
} finally {
|
||||
this.guardando = false
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Eliminar una regla
|
||||
*/
|
||||
async eliminarRegla(reglaId) {
|
||||
this.guardando = true
|
||||
this.error = null
|
||||
try {
|
||||
const { data } = await api.delete(`/reglas/${reglaId}`)
|
||||
|
||||
// Eliminar del store
|
||||
this.reglas = this.reglas.filter(r => r.regla_id !== reglaId)
|
||||
|
||||
// Actualizar contadores
|
||||
if (data.total_preguntas_asignadas !== undefined) {
|
||||
this.totalPreguntasAsignadas = data.total_preguntas_asignadas
|
||||
this.preguntasDisponibles = this.proceso.cantidad_total_preguntas - this.totalPreguntasAsignadas
|
||||
}
|
||||
|
||||
return { success: true, data }
|
||||
} catch (err) {
|
||||
console.error('Error al eliminar regla:', err)
|
||||
this.error = err.response?.data?.message || 'Error al eliminar regla'
|
||||
return {
|
||||
success: false,
|
||||
error: this.error
|
||||
}
|
||||
} finally {
|
||||
this.guardando = false
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Reordenar reglas
|
||||
*/
|
||||
async reordenarReglas(reglasOrdenadas) {
|
||||
// Primero actualizar localmente
|
||||
reglasOrdenadas.forEach((regla, index) => {
|
||||
const reglaIndex = this.reglas.findIndex(r => r.curso_id === regla.curso_id)
|
||||
if (reglaIndex >= 0) {
|
||||
this.reglas[reglaIndex].orden = index + 1
|
||||
}
|
||||
})
|
||||
|
||||
// Ordenar array local
|
||||
this.reglas.sort((a, b) => a.orden - b.orden)
|
||||
|
||||
// Si hay reglas con ID (existentes), actualizar en backend
|
||||
const reglasConId = this.reglas.filter(r => r.regla_id)
|
||||
|
||||
if (reglasConId.length > 0) {
|
||||
// Podrías hacer una actualización múltiple o individual
|
||||
// Aquí asumimos que cada regla se actualiza individualmente
|
||||
for (const regla of reglasConId) {
|
||||
if (regla.regla_id) {
|
||||
await this.editarRegla(regla.regla_id, { orden: regla.orden })
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Calcular preguntas disponibles para un curso específico
|
||||
*/
|
||||
calcularPreguntasDisponibles(cursoId, cantidadActual = 0) {
|
||||
const otrasReglas = this.reglas.filter(r => r.curso_id !== cursoId)
|
||||
const totalOtras = otrasReglas.reduce((sum, r) => sum + (r.cantidad_preguntas || 0), 0)
|
||||
return Math.max(0, this.totalPreguntasProceso - totalOtras - cantidadActual)
|
||||
},
|
||||
|
||||
/**
|
||||
* Resetear store
|
||||
*/
|
||||
reset() {
|
||||
this.areaProcesoId = null
|
||||
this.proceso = null
|
||||
this.reglas = []
|
||||
this.totalPreguntasAsignadas = 0
|
||||
this.preguntasDisponibles = 0
|
||||
this.error = null
|
||||
},
|
||||
}
|
||||
})
|
||||
@ -0,0 +1,769 @@
|
||||
<template>
|
||||
<div class="reglas-container">
|
||||
<!-- Header -->
|
||||
<div class="reglas-header">
|
||||
<div class="header-title">
|
||||
<h2>Reglas de Evaluación</h2>
|
||||
<p class="subtitle">Configuración de reglas por área-proceso</p>
|
||||
</div>
|
||||
<a-button v-if="store.areaProcesoId" @click="volverALista" size="large">
|
||||
<template #icon><ArrowLeftOutlined /></template>
|
||||
Volver
|
||||
</a-button>
|
||||
</div>
|
||||
|
||||
<!-- Selección de Área Proceso -->
|
||||
<div v-if="!store.areaProcesoId" class="seleccion-section">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h3>Seleccionar Área Proceso</h3>
|
||||
<p>Selecciona un área-proceso para configurar sus reglas</p>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<!-- Filtros -->
|
||||
<div class="filters">
|
||||
<a-input-search
|
||||
v-model:value="filtroBusqueda"
|
||||
placeholder="Buscar área..."
|
||||
@search="filtrarAreas"
|
||||
style="width: 300px"
|
||||
/>
|
||||
<a-select
|
||||
v-model:value="filtroProceso"
|
||||
placeholder="Filtrar por proceso"
|
||||
style="width: 250px; margin-left: 16px"
|
||||
@change="filtrarAreas"
|
||||
>
|
||||
<a-select-option :value="null">Todos los procesos</a-select-option>
|
||||
<a-select-option
|
||||
v-for="proceso in procesosUnicos"
|
||||
:key="proceso.id"
|
||||
:value="proceso.id"
|
||||
>
|
||||
{{ proceso.nombre }}
|
||||
</a-select-option>
|
||||
</a-select>
|
||||
<a-button @click="limpiarFiltros" style="margin-left: 16px">
|
||||
<ReloadOutlined /> Limpiar
|
||||
</a-button>
|
||||
</div>
|
||||
|
||||
<!-- Tabla de Áreas Proceso -->
|
||||
<a-table
|
||||
:data-source="areasProcesoFiltradas"
|
||||
:columns="columnasAreas"
|
||||
:loading="store.cargando"
|
||||
:pagination="{ pageSize: 10 }"
|
||||
row-key="id"
|
||||
@rowClick="seleccionarAreaProceso"
|
||||
class="clickable-table"
|
||||
>
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.key === 'cursos'">
|
||||
<a-tag color="blue">{{ record.cursos_count || 0 }} cursos</a-tag>
|
||||
|
||||
</template>
|
||||
<template v-if="column.key === 'reglas_count'">
|
||||
<a-tag :color="record.reglas_count > 0 ? 'green' : 'default'">
|
||||
{{ record.reglas_count || 0 }} reglas
|
||||
</a-tag>
|
||||
|
||||
</template>
|
||||
<template v-if="column.key === 'accion'">
|
||||
<a-button type="primary" size="small" @click.stop="seleccionarAreaProceso(record)">
|
||||
Configurar
|
||||
</a-button>
|
||||
</template>
|
||||
</template>
|
||||
</a-table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Configuración de Reglas -->
|
||||
<div v-else class="configuracion-section">
|
||||
<!-- Header del Proceso -->
|
||||
<div class="proceso-info">
|
||||
<div class="proceso-header">
|
||||
<h3>{{ store.proceso?.nombre }} <span class="separator">/</span> {{ nombreAreaActual }}</h3>
|
||||
<div class="proceso-stats">
|
||||
<div class="stat"><span class="stat-label">Total Preguntas:</span> <span class="stat-value">{{ store.totalPreguntasProceso }}</span></div>
|
||||
<div class="stat"><span class="stat-label">Asignadas:</span> <span class="stat-value">{{ store.totalPreguntasAsignadas }}</span></div>
|
||||
<div class="stat"><span class="stat-label">Disponibles:</span> <span class="stat-value" :class="{ 'cero': store.preguntasDisponibles === 0 }">{{ store.preguntasDisponibles }}</span></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Barra de Progreso -->
|
||||
<div class="progress-container">
|
||||
<div class="progress-info">
|
||||
<span>Progreso de asignación</span>
|
||||
<span>{{ porcentajeAsignado }}%</span>
|
||||
</div>
|
||||
<a-progress
|
||||
:percent="porcentajeAsignado"
|
||||
:stroke-color="store.limiteAlcanzado ? '#ff4d4f' : '#52c41a'"
|
||||
:status="store.limiteAlcanzado ? 'exception' : 'normal'"
|
||||
/>
|
||||
<div v-if="store.limiteAlcanzado" class="alerta">
|
||||
<a-alert type="warning" show-icon message="Se ha alcanzado el límite de preguntas" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Acciones Rápidas -->
|
||||
<div class="acciones-rapidas">
|
||||
<a-button @click="autoDistribuir" :disabled="store.preguntasDisponibles === 0">
|
||||
<template #icon><RobotOutlined /></template> Distribuir Automático
|
||||
</a-button>
|
||||
<a-button type="primary" @click="guardarTodo" :loading="store.guardando">
|
||||
<template #icon><SaveOutlined /></template> Guardar Todo
|
||||
</a-button>
|
||||
<a-button @click="restablecerCambios" danger>
|
||||
<template #icon><UndoOutlined /></template> Deshacer Cambios
|
||||
</a-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tabla de Reglas -->
|
||||
<div class="reglas-table">
|
||||
<a-table
|
||||
:data-source="store.reglas"
|
||||
:columns="columnasReglas"
|
||||
:loading="store.cargando"
|
||||
row-key="curso_id"
|
||||
:pagination="false"
|
||||
>
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.key === 'orden'">
|
||||
<div class="orden-controls">
|
||||
<a-button size="small" @click="cambiarOrden(record, -1)" :disabled="esPrimeraRegla(record)"><UpOutlined /></a-button>
|
||||
<a-input-number v-model:value="record.orden" :min="1" :max="store.reglas.length" @change="marcarComoModificado(record)" size="small" style="width: 60px; margin: 0 4px" />
|
||||
<a-button size="small" @click="cambiarOrden(record, 1)" :disabled="esUltimaRegla(record)"><DownOutlined /></a-button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-if="column.key === 'curso'">
|
||||
<div class="curso-info"><strong>{{ record.nombre }}</strong><small>ID: {{ record.curso_id }}</small></div>
|
||||
</template>
|
||||
|
||||
<template v-if="column.key === 'preguntas'">
|
||||
<div class="preguntas-control">
|
||||
<a-input-number
|
||||
v-model:value="record.cantidad_preguntas"
|
||||
:min="0"
|
||||
:max="maxPreguntasPorCurso(record)"
|
||||
@change="validarPreguntas(record)"
|
||||
style="width: 100px"
|
||||
:name="'cantidad_' + record.curso_id"
|
||||
/>
|
||||
|
||||
<div class="preguntas-info">
|
||||
<small>Máx: {{ maxPreguntasPorCurso(record) }}</small>
|
||||
<small v-if="tieneErrorPreguntas(record)" class="error"><ExclamationCircleOutlined /> Excede límite</small>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-if="column.key === 'dificultad'">
|
||||
<a-select v-model:value="record.nivel_dificultad" @change="marcarComoModificado(record)" style="width: 120px">
|
||||
<a-select-option value="bajo"><a-tag color="green">Bajo</a-tag></a-select-option>
|
||||
<a-select-option value="medio"><a-tag color="orange">Medio</a-tag></a-select-option>
|
||||
<a-select-option value="alto"><a-tag color="red">Alto</a-tag></a-select-option>
|
||||
</a-select>
|
||||
</template>
|
||||
|
||||
<template v-if="column.key === 'ponderacion'">
|
||||
<div class="ponderacion-control">
|
||||
<a-input-number
|
||||
v-model:value="record.ponderacion"
|
||||
:min="0"
|
||||
:max="100"
|
||||
:step="0.1"
|
||||
:precision="2"
|
||||
string-mode
|
||||
style="width: 120px"
|
||||
@change="marcarComoModificado(record)"
|
||||
/>
|
||||
<span style="margin-left: 4px"></span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
<template v-if="column.key === 'acciones'">
|
||||
<a-space>
|
||||
<a-button type="primary" size="small" @click="guardarReglaIndividual(record)" :disabled="!reglaModificada(record) || store.guardando" :loading="guardandoIndividual[record.curso_id]"><SaveOutlined /></a-button>
|
||||
<a-button danger size="small" @click="eliminarRegla(record)" v-if="record.regla_id" :disabled="store.guardando"><DeleteOutlined /></a-button>
|
||||
</a-space>
|
||||
</template>
|
||||
|
||||
<template v-if="column.key === 'estado'">
|
||||
<a-tag v-if="reglaModificada(record)" color="orange"><EditOutlined /> Modificado</a-tag>
|
||||
<a-tag v-else-if="record.regla_id" color="green"><CheckCircleOutlined /> Guardado</a-tag>
|
||||
<a-tag v-else color="default"><ClockCircleOutlined /> Sin guardar</a-tag>
|
||||
</template>
|
||||
</template>
|
||||
</a-table>
|
||||
</div>
|
||||
|
||||
<!-- Resumen -->
|
||||
<div class="resumen-section">
|
||||
<a-card title="Resumen">
|
||||
<div class="resumen-grid">
|
||||
<div class="resumen-item"><span class="resumen-label">Cursos configurados:</span> <span class="resumen-value">{{ cursosConfigurados }} / {{ store.reglas.length }}</span></div>
|
||||
<div class="resumen-item"><span class="resumen-label">Total preguntas asignadas:</span> <span class="resumen-value">{{ store.totalPreguntasAsignadas }}</span></div>
|
||||
<div class="resumen-item"><span class="resumen-label">Preguntas disponibles:</span> <span class="resumen-value">{{ store.preguntasDisponibles }}</span></div>
|
||||
<div class="resumen-item"><span class="resumen-label">Porcentaje completado:</span> <span class="resumen-value">{{ porcentajeAsignado }}%</span></div>
|
||||
</div>
|
||||
</a-card>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Modal de Confirmación -->
|
||||
<a-modal
|
||||
v-model:open="showConfirmModal"
|
||||
title="Confirmación"
|
||||
ok-text="Confirmar"
|
||||
cancel-text="Cancelar"
|
||||
@ok="confirmarAccion"
|
||||
@cancel="cancelarAccion"
|
||||
>
|
||||
<p>{{ confirmMessage }}</p>
|
||||
</a-modal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useReglaAreaProcesoStore } from '../../../store/reglaAreaProceso.store'
|
||||
import { message } from 'ant-design-vue'
|
||||
import {
|
||||
ArrowLeftOutlined,
|
||||
SaveOutlined,
|
||||
DeleteOutlined,
|
||||
EditOutlined,
|
||||
CheckCircleOutlined,
|
||||
ClockCircleOutlined,
|
||||
UpOutlined,
|
||||
DownOutlined,
|
||||
RobotOutlined,
|
||||
UndoOutlined,
|
||||
ReloadOutlined,
|
||||
ExclamationCircleOutlined
|
||||
} from '@ant-design/icons-vue'
|
||||
|
||||
const store = useReglaAreaProcesoStore()
|
||||
|
||||
// Estados
|
||||
const filtroBusqueda = ref('')
|
||||
const filtroProceso = ref(null)
|
||||
const areasProceso = ref([]) // inicializado seguro
|
||||
const areasProcesoFiltradas = ref([]) // inicializado seguro
|
||||
const reglasOriginales = ref([]) // copia de reglas originales
|
||||
const reglasModificadas = ref([]) // ids de reglas modificadas
|
||||
const guardandoIndividual = ref({})
|
||||
const showConfirmModal = ref(false)
|
||||
const confirmMessage = ref('')
|
||||
const confirmAction = ref(null)
|
||||
const confirmData = ref(null)
|
||||
const nombreAreaActual = ref('')
|
||||
|
||||
// Computed
|
||||
const porcentajeAsignado = computed(() => {
|
||||
return store.totalPreguntasProceso === 0
|
||||
? 0
|
||||
: Math.round((store.totalPreguntasAsignadas / store.totalPreguntasProceso) * 100)
|
||||
})
|
||||
|
||||
const cursosConfigurados = computed(() => {
|
||||
if (!Array.isArray(store.reglas)) return 0
|
||||
return store.reglas.filter(r => r.cantidad_preguntas > 0).length
|
||||
})
|
||||
|
||||
// Computed defensivo para procesos únicos
|
||||
const procesosUnicos = computed(() => {
|
||||
if (!Array.isArray(areasProceso.value)) return []
|
||||
|
||||
const procesos = areasProceso.value.map(ap => ({
|
||||
id: ap.proceso_id,
|
||||
nombre: ap.proceso_nombre
|
||||
}))
|
||||
// eliminar duplicados por id
|
||||
return [...new Map(procesos.map(p => [p.id, p])).values()]
|
||||
})
|
||||
|
||||
// Columnas
|
||||
const columnasAreas = [
|
||||
{ title: 'ID', dataIndex: 'id', key: 'id', width: 80 },
|
||||
{ title: 'Área', dataIndex: 'area_nombre', key: 'area' },
|
||||
{ title: 'Proceso', dataIndex: 'proceso_nombre', key: 'proceso' },
|
||||
{ title: 'Cursos', key: 'cursos', width: 100, align: 'center' },
|
||||
{ title: 'Reglas Configuradas', key: 'reglas_count', width: 120, align: 'center' },
|
||||
{ title: 'Acción', key: 'accion', width: 100, align: 'center' }
|
||||
]
|
||||
|
||||
const columnasReglas = [
|
||||
{ title: 'Orden', key: 'orden', width: 120 },
|
||||
{ title: 'Curso', key: 'curso' },
|
||||
{ title: 'Preguntas', key: 'preguntas', width: 150 },
|
||||
{ title: 'Dificultad', key: 'dificultad', width: 120 },
|
||||
{ title: 'Ponderación', key: 'ponderacion', width: 200 },
|
||||
{ title: 'Estado', key: 'estado', width: 100 },
|
||||
{ title: 'Acciones', key: 'acciones', width: 120 }
|
||||
]
|
||||
|
||||
// Métodos
|
||||
const cargarAreasProceso = async () => {
|
||||
await store.cargarAreaProcesos()
|
||||
areasProceso.value = Array.isArray(store.areaProcesos) ? store.areaProcesos : []
|
||||
areasProcesoFiltradas.value = [...areasProceso.value]
|
||||
}
|
||||
|
||||
const filtrarAreas = () => {
|
||||
let filtradas = Array.isArray(areasProceso.value) ? areasProceso.value : []
|
||||
|
||||
if (filtroProceso.value) {
|
||||
filtradas = filtradas.filter(ap => ap.proceso_id === filtroProceso.value)
|
||||
}
|
||||
|
||||
if (filtroBusqueda.value) {
|
||||
const busqueda = filtroBusqueda.value.toLowerCase()
|
||||
filtradas = filtradas.filter(ap =>
|
||||
(ap.area_nombre || '').toLowerCase().includes(busqueda) ||
|
||||
(ap.proceso_nombre || '').toLowerCase().includes(busqueda)
|
||||
)
|
||||
}
|
||||
|
||||
areasProcesoFiltradas.value = filtradas
|
||||
}
|
||||
|
||||
const limpiarFiltros = () => {
|
||||
filtroBusqueda.value = ''
|
||||
filtroProceso.value = null
|
||||
areasProcesoFiltradas.value = [...areasProceso.value]
|
||||
}
|
||||
|
||||
const seleccionarAreaProceso = async (areaProceso) => {
|
||||
if (!areaProceso?.id) return
|
||||
await store.setAreaProceso(areaProceso.id)
|
||||
reglasOriginales.value = Array.isArray(store.reglas) ? JSON.parse(JSON.stringify(store.reglas)) : []
|
||||
reglasModificadas.value = []
|
||||
nombreAreaActual.value = areaProceso?.area?.nombre || ''
|
||||
}
|
||||
|
||||
const volverALista = () => {
|
||||
if (reglasModificadas.value.length > 0) {
|
||||
confirmMessage.value = 'Tienes cambios sin guardar. ¿Seguro que quieres salir?'
|
||||
confirmAction.value = confirmarVolver
|
||||
showConfirmModal.value = true
|
||||
} else {
|
||||
store.reset()
|
||||
}
|
||||
}
|
||||
|
||||
const confirmarVolver = () => {
|
||||
store.reset()
|
||||
showConfirmModal.value = false
|
||||
}
|
||||
|
||||
const maxPreguntasPorCurso = (regla) => store.calcularPreguntasDisponibles(regla?.curso_id, regla?.cantidad_preguntas || 0)
|
||||
const tieneErrorPreguntas = (regla) => regla?.cantidad_preguntas > maxPreguntasPorCurso(regla)
|
||||
const validarPreguntas = (regla) => {
|
||||
if (tieneErrorPreguntas(regla)) regla.cantidad_preguntas = maxPreguntasPorCurso(regla)
|
||||
marcarComoModificado(regla)
|
||||
}
|
||||
const reglaModificada = (regla) => {
|
||||
if (!Array.isArray(reglasOriginales.value)) return true
|
||||
const original = reglasOriginales.value.find(r => r.curso_id === regla?.curso_id)
|
||||
if (!original) return true
|
||||
return original.orden !== regla.orden ||
|
||||
original.cantidad_preguntas !== regla.cantidad_preguntas ||
|
||||
original.nivel_dificultad !== regla.nivel_dificultad ||
|
||||
original.ponderacion !== regla.ponderacion
|
||||
}
|
||||
const marcarComoModificado = (regla) => {
|
||||
if (!regla?.curso_id) return
|
||||
if (reglaModificada(regla) && !reglasModificadas.value.includes(regla.curso_id)) {
|
||||
reglasModificadas.value.push(regla.curso_id)
|
||||
}
|
||||
}
|
||||
|
||||
const esPrimeraRegla = (regla) => store.reglas.findIndex(r => r?.curso_id === regla?.curso_id) === 0
|
||||
const esUltimaRegla = (regla) => store.reglas.findIndex(r => r?.curso_id === regla?.curso_id) === (Array.isArray(store.reglas) ? store.reglas.length - 1 : 0)
|
||||
|
||||
// Funciones CRUD y acciones (defensivas)
|
||||
const cambiarOrden = (regla, direccion) => {
|
||||
if (!Array.isArray(store.reglas)) return
|
||||
const index = store.reglas.findIndex(r => r?.curso_id === regla?.curso_id)
|
||||
const nuevoIndex = index + direccion
|
||||
if (nuevoIndex < 0 || nuevoIndex >= store.reglas.length) return
|
||||
const temp = store.reglas[index]; store.reglas[index] = store.reglas[nuevoIndex]; store.reglas[nuevoIndex] = temp
|
||||
const tempOrden = store.reglas[index].orden; store.reglas[index].orden = store.reglas[nuevoIndex].orden; store.reglas[nuevoIndex].orden = tempOrden
|
||||
marcarComoModificado(store.reglas[index])
|
||||
marcarComoModificado(store.reglas[nuevoIndex])
|
||||
}
|
||||
// Guardar todas las reglas modificadas
|
||||
const guardarTodo = async () => {
|
||||
if (!Array.isArray(reglasModificadas.value) || reglasModificadas.value.length === 0) {
|
||||
message.info('No hay cambios para guardar')
|
||||
return
|
||||
}
|
||||
|
||||
const reglasAGuardar = store.reglas.filter(r => reglasModificadas.value.includes(r.curso_id))
|
||||
const { success, error } = await store.guardarReglasMultiple(reglasAGuardar)
|
||||
if (success) {
|
||||
message.success('Reglas guardadas correctamente')
|
||||
reglasModificadas.value = [] // reset
|
||||
} else {
|
||||
message.error(error || 'Error al guardar reglas')
|
||||
}
|
||||
}
|
||||
const guardarReglaIndividual = async (regla) => {
|
||||
if (!store) return
|
||||
const result = await store.guardarRegla(regla)
|
||||
if (!result.success) {
|
||||
console.error('Error al guardar regla individual:', result.error)
|
||||
}
|
||||
}
|
||||
|
||||
// Restablecer cambios locales
|
||||
const restablecerCambios = () => {
|
||||
reglasModificadas.value = []
|
||||
// Restaurar reglas a su estado original
|
||||
if (Array.isArray(reglasOriginales.value)) {
|
||||
store.reglas = JSON.parse(JSON.stringify(reglasOriginales.value))
|
||||
}
|
||||
message.info('Cambios deshechos')
|
||||
}
|
||||
|
||||
const autoDistribuir = () => {
|
||||
if (!Array.isArray(store.reglas) || store.reglas.length === 0) {
|
||||
message.info('No hay reglas para distribuir')
|
||||
return
|
||||
}
|
||||
|
||||
// Total de preguntas disponibles
|
||||
let disponibles = store.preguntasDisponibles
|
||||
|
||||
// Reglas que aún no tienen preguntas asignadas
|
||||
const reglasSinPreguntas = store.reglas.filter(r => !r.cantidad_preguntas || r.cantidad_preguntas === 0)
|
||||
|
||||
if (reglasSinPreguntas.length === 0) {
|
||||
message.info('Todos los cursos ya tienen preguntas asignadas')
|
||||
return
|
||||
}
|
||||
|
||||
// Distribuir de forma equitativa
|
||||
const cantidadPorCurso = Math.floor(disponibles / reglasSinPreguntas.length)
|
||||
reglasSinPreguntas.forEach(r => {
|
||||
r.cantidad_preguntas = cantidadPorCurso
|
||||
marcarComoModificado(r)
|
||||
})
|
||||
|
||||
message.success('Preguntas distribuidas automáticamente')
|
||||
}
|
||||
|
||||
// Más métodos de guardado y eliminación...
|
||||
// (igual que tu código original, solo asegurando ?. y Array.isArray defensivo)
|
||||
|
||||
const confirmarAccion = () => { if(confirmAction.value) confirmAction.value() }
|
||||
const cancelarAccion = () => {
|
||||
showConfirmModal.value = false
|
||||
confirmAction.value = null
|
||||
confirmData.value = null
|
||||
}
|
||||
|
||||
onMounted(() => { cargarAreasProceso() })
|
||||
</script>
|
||||
|
||||
|
||||
<style scoped>
|
||||
.reglas-container {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.reglas-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 24px;
|
||||
padding: 0 8px;
|
||||
}
|
||||
|
||||
.header-title h2 {
|
||||
margin: 0;
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
color: #1f1f1f;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
margin: 4px 0 0 0;
|
||||
color: #666;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
/* Selección */
|
||||
.seleccion-section {
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.card {
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
|
||||
border: 1px solid #f0f0f0;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.card-header {
|
||||
padding: 20px 24px;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
}
|
||||
|
||||
.card-header h3 {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
color: #1f1f1f;
|
||||
}
|
||||
|
||||
.card-header p {
|
||||
margin: 4px 0 0 0;
|
||||
color: #666;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.card-body {
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.filters {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.clickable-table :deep(.ant-table-tbody > tr) {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.clickable-table :deep(.ant-table-tbody > tr:hover) {
|
||||
background: #fafafa;
|
||||
}
|
||||
|
||||
/* Configuración */
|
||||
.configuracion-section {
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.proceso-info {
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
|
||||
border: 1px solid #f0f0f0;
|
||||
padding: 24px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.proceso-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.proceso-header h3 {
|
||||
margin: 0;
|
||||
font-size: 20px;
|
||||
color: #1f1f1f;
|
||||
}
|
||||
|
||||
.separator {
|
||||
color: #999;
|
||||
margin: 0 8px;
|
||||
}
|
||||
|
||||
.proceso-stats {
|
||||
display: flex;
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.stat {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 12px;
|
||||
color: #666;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: #1f1f1f;
|
||||
}
|
||||
|
||||
.stat-value.cero {
|
||||
color: #ff4d4f;
|
||||
}
|
||||
|
||||
.progress-container {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.progress-info {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 8px;
|
||||
font-size: 14px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.alerta {
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.acciones-rapidas {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
/* Tabla de Reglas */
|
||||
.reglas-table {
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
|
||||
border: 1px solid #f0f0f0;
|
||||
margin-bottom: 24px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.orden-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.curso-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.curso-info small {
|
||||
color: #999;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.preguntas-control {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.preguntas-info {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.preguntas-info .error {
|
||||
color: #ff4d4f;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.ponderacion-control {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
/* Resumen */
|
||||
.resumen-section {
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
|
||||
border: 1px solid #f0f0f0;
|
||||
}
|
||||
|
||||
.resumen-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.resumen-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 16px;
|
||||
background: #fafafa;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.resumen-label {
|
||||
font-size: 12px;
|
||||
color: #666;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.resumen-value {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: #1f1f1f;
|
||||
}
|
||||
|
||||
/* Responsive */
|
||||
@media (max-width: 1200px) {
|
||||
.resumen-grid {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
|
||||
.proceso-header {
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.proceso-stats {
|
||||
width: 100%;
|
||||
justify-content: space-between;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.reglas-header {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.filters {
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.filters > * {
|
||||
width: 100%;
|
||||
margin-left: 0 !important;
|
||||
}
|
||||
|
||||
.acciones-rapidas {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.resumen-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.reglas-table {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.reglas-table :deep(.ant-table) {
|
||||
min-width: 800px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@ -0,0 +1,355 @@
|
||||
<template>
|
||||
<a-card title="Mi Examen" :loading="examenStore.cargando">
|
||||
<!-- Estado de carga -->
|
||||
<template v-if="examenStore.cargando && !examenStore.examenActual">
|
||||
<a-skeleton active />
|
||||
</template>
|
||||
|
||||
<!-- Si no hay examen asignado -->
|
||||
<div v-else-if="!examenStore.examenActual" class="no-examen">
|
||||
<a-empty description="No tienes un examen asignado actualmente">
|
||||
<template #extra>
|
||||
<a-button type="primary" @click="showModal = true">
|
||||
Asignar Examen
|
||||
</a-button>
|
||||
</template>
|
||||
</a-empty>
|
||||
</div>
|
||||
|
||||
<!-- Si hay examen -->
|
||||
<div v-else class="examen-info">
|
||||
<a-descriptions title="Información del Examen" bordered>
|
||||
<a-descriptions-item label="Proceso">
|
||||
{{ examenStore.examenActual.proceso?.nombre || 'No asignado' }}
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="Área">
|
||||
{{ examenStore.examenActual.area?.nombre || 'No asignado' }}
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="Intentos realizados">
|
||||
{{ examenStore.examenActual.intentos || 0 }}
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="Estado del examen">
|
||||
<a-tag :color="getEstadoColor">
|
||||
{{ getEstadoTexto }}
|
||||
</a-tag>
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="Pago" v-if="examenStore.examenActual.pagado">
|
||||
<a-tag color="green">Pagado ({{ examenStore.examenActual.tipo_pago }})</a-tag>
|
||||
</a-descriptions-item>
|
||||
</a-descriptions>
|
||||
|
||||
<div class="actions-container" style="margin-top: 24px;">
|
||||
<!-- Botón Seleccionar Área (solo si no tiene área) -->
|
||||
<a-button
|
||||
v-if="!examenStore.examenActual.area"
|
||||
type="primary"
|
||||
@click="showModal = true"
|
||||
style="margin-right: 8px;"
|
||||
>
|
||||
Seleccionar Área
|
||||
</a-button>
|
||||
|
||||
<!-- Botón Iniciar Examen (solo si hay examen y no hay intentos) -->
|
||||
<a-button
|
||||
v-if="examenStore.examenActual && (!examenStore.examenActual.intentos || examenStore.examenActual.intentos === 0)"
|
||||
type="primary"
|
||||
:loading="iniciandoExamen"
|
||||
@click="irAlExamen"
|
||||
style="margin-right: 8px;"
|
||||
>
|
||||
Iniciar Examen
|
||||
</a-button>
|
||||
|
||||
<!-- Botón Ver Resultados (solo si hay intentos) -->
|
||||
<a-button
|
||||
v-if="examenStore.examenActual?.intentos > 0"
|
||||
type="default"
|
||||
@click="verResultado"
|
||||
>
|
||||
Ver Resultados
|
||||
</a-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Modal para seleccionar área -->
|
||||
<a-modal
|
||||
v-model:visible="showModal"
|
||||
title="Seleccionar Área de Examen"
|
||||
:confirm-loading="creandoExamen"
|
||||
@ok="crearExamen"
|
||||
@cancel="resetModal"
|
||||
:mask-closable="false"
|
||||
width="600px"
|
||||
>
|
||||
<a-form :model="formState" :rules="rules" layout="vertical">
|
||||
<!-- Selección de Proceso -->
|
||||
<a-form-item label="Proceso" name="proceso_id" required>
|
||||
<a-select
|
||||
v-model:value="formState.proceso_id"
|
||||
placeholder="Seleccione un proceso"
|
||||
:options="procesoOptions"
|
||||
@change="handleProcesoChange"
|
||||
:loading="examenStore.cargando"
|
||||
/>
|
||||
</a-form-item>
|
||||
|
||||
<!-- Selección de Área -->
|
||||
<a-form-item label="Área" name="area_proceso_id" required>
|
||||
<a-select
|
||||
v-model:value="formState.area_proceso_id"
|
||||
placeholder="Seleccione un área"
|
||||
:options="areaOptions"
|
||||
:disabled="!formState.proceso_id"
|
||||
:loading="examenStore.cargando"
|
||||
/>
|
||||
</a-form-item>
|
||||
|
||||
<!-- Información de pago (si el proceso lo requiere) -->
|
||||
<div v-if="procesoRequierePago">
|
||||
<a-alert
|
||||
message="Este proceso requiere pago"
|
||||
type="info"
|
||||
show-icon
|
||||
style="margin-bottom: 16px;"
|
||||
/>
|
||||
|
||||
<a-form-item label="Tipo de Pago" name="tipo_pago" required>
|
||||
<a-select
|
||||
v-model:value="formState.tipo_pago"
|
||||
placeholder="Seleccione tipo de pago"
|
||||
:options="tipoPagoOptions"
|
||||
/>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item label="Código de Pago" name="codigo_pago" required>
|
||||
<a-input
|
||||
v-model:value="formState.codigo_pago"
|
||||
placeholder="Ingrese el código de pago"
|
||||
/>
|
||||
</a-form-item>
|
||||
</div>
|
||||
</a-form>
|
||||
</a-modal>
|
||||
</a-card>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, reactive, onMounted, watch } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useExamenStore } from '../../store/examen.store'
|
||||
import { message, Modal } from 'ant-design-vue'
|
||||
|
||||
const router = useRouter()
|
||||
const examenStore = useExamenStore()
|
||||
|
||||
// Estados reactivos
|
||||
const showModal = ref(false)
|
||||
const iniciandoExamen = ref(false)
|
||||
const creandoExamen = ref(false)
|
||||
|
||||
// Formulario para crear examen
|
||||
const formState = reactive({
|
||||
proceso_id: undefined,
|
||||
area_proceso_id: undefined,
|
||||
tipo_pago: undefined,
|
||||
codigo_pago: ''
|
||||
})
|
||||
|
||||
// Reglas de validación
|
||||
const rules = {
|
||||
proceso_id: [{ required: true, message: 'Por favor seleccione un proceso' }],
|
||||
area_proceso_id: [{ required: true, message: 'Por favor seleccione un área' }],
|
||||
tipo_pago: [{
|
||||
required: computed(() => procesoRequierePago.value),
|
||||
message: 'Por favor seleccione tipo de pago'
|
||||
}],
|
||||
codigo_pago: [{
|
||||
required: computed(() => procesoRequierePago.value),
|
||||
message: 'Por favor ingrese código de pago'
|
||||
}]
|
||||
}
|
||||
|
||||
// Opciones para selects
|
||||
const procesoOptions = computed(() => {
|
||||
return examenStore.procesos.map(p => ({
|
||||
value: p.id,
|
||||
label: p.nombre,
|
||||
requiere_pago: p.requiere_pago
|
||||
}))
|
||||
})
|
||||
|
||||
const areaOptions = computed(() => {
|
||||
return examenStore.areas.map(a => ({
|
||||
value: a.area_proceso_id,
|
||||
label: a.nombre
|
||||
}))
|
||||
})
|
||||
|
||||
const tipoPagoOptions = [
|
||||
{ value: 'pyto_peru', label: 'Pago por Pytoperú' },
|
||||
{ value: 'banco_nacion', label: 'Banco de la Nación' },
|
||||
{ value: 'caja', label: 'Caja' }
|
||||
]
|
||||
|
||||
// Computed properties
|
||||
const procesoRequierePago = computed(() => {
|
||||
const proceso = examenStore.procesos.find(p => p.id === formState.proceso_id)
|
||||
return proceso?.requiere_pago === 1
|
||||
})
|
||||
|
||||
const getEstadoColor = computed(() => {
|
||||
if (!examenStore.examenActual?.intentos || examenStore.examenActual.intentos === 0) {
|
||||
return 'blue'
|
||||
}
|
||||
return 'green'
|
||||
})
|
||||
|
||||
const getEstadoTexto = computed(() => {
|
||||
if (!examenStore.examenActual?.intentos || examenStore.examenActual.intentos === 0) {
|
||||
return 'Disponible'
|
||||
}
|
||||
return 'Completado'
|
||||
})
|
||||
|
||||
// Métodos
|
||||
const handleProcesoChange = async (procesoId) => {
|
||||
formState.area_proceso_id = undefined
|
||||
if (procesoId) {
|
||||
await examenStore.fetchAreas(procesoId)
|
||||
}
|
||||
}
|
||||
|
||||
const crearExamen = async () => {
|
||||
try {
|
||||
creandoExamen.value = true
|
||||
|
||||
const pagoData = procesoRequierePago.value ? {
|
||||
tipo_pago: formState.tipo_pago,
|
||||
codigo_pago: formState.codigo_pago
|
||||
} : null
|
||||
|
||||
const result = await examenStore.crearExamen(
|
||||
formState.area_proceso_id,
|
||||
pagoData
|
||||
)
|
||||
|
||||
if (result.success) {
|
||||
message.success('Examen creado correctamente')
|
||||
showModal.value = false
|
||||
resetModal()
|
||||
// Recargar el examen actual
|
||||
await examenStore.fetchExamenActual()
|
||||
} else {
|
||||
message.error(result.message || 'Error al crear examen')
|
||||
}
|
||||
} catch (error) {
|
||||
message.error('Error al crear examen')
|
||||
} finally {
|
||||
creandoExamen.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const irAlExamen = async () => {
|
||||
if (!examenStore.examenActual?.id) {
|
||||
message.error('No hay examen para iniciar')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
iniciandoExamen.value = true
|
||||
|
||||
// Primero intentar generar preguntas
|
||||
const generarResult = await examenStore.generarPreguntas(examenStore.examenActual.id)
|
||||
|
||||
if (!generarResult.success) {
|
||||
// Si hay error real, mostrar mensaje
|
||||
if (!generarResult.ya_tiene_preguntas) {
|
||||
message.error(generarResult.message || 'Error al generar preguntas')
|
||||
return
|
||||
}
|
||||
// Si ya tiene preguntas, es un éxito (continuar)
|
||||
}
|
||||
|
||||
// Luego iniciar el examen (esto carga las preguntas)
|
||||
const iniciarResult = await examenStore.iniciarExamen(examenStore.examenActual.id)
|
||||
|
||||
if (iniciarResult.success) {
|
||||
// Redirigir al panel de examen
|
||||
router.push({ name: 'PanelExamen', params: { examenId: examenStore.examenActual.id } })
|
||||
|
||||
} else {
|
||||
message.error(iniciarResult.message || 'Error al iniciar examen')
|
||||
}
|
||||
} catch (error) {
|
||||
message.error('Error al procesar la solicitud')
|
||||
console.error(error)
|
||||
} finally {
|
||||
iniciandoExamen.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const verResultado = async () => {
|
||||
// Actualizar intentos del examen
|
||||
await examenStore.fetchExamenActual()
|
||||
|
||||
// Redirigir a resultados
|
||||
router.push({
|
||||
name: 'PanelResultados',
|
||||
params: { examenId: examenStore.examenActual?.id }
|
||||
})
|
||||
}
|
||||
|
||||
const resetModal = () => {
|
||||
Object.keys(formState).forEach(key => {
|
||||
if (key === 'proceso_id' || key === 'area_proceso_id') {
|
||||
formState[key] = undefined
|
||||
} else if (key === 'codigo_pago') {
|
||||
formState[key] = ''
|
||||
} else {
|
||||
formState[key] = undefined
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Lifecycle
|
||||
onMounted(async () => {
|
||||
// Cargar procesos disponibles
|
||||
await examenStore.fetchProcesos()
|
||||
|
||||
// Cargar examen actual
|
||||
await examenStore.fetchExamenActual()
|
||||
})
|
||||
|
||||
// Watch para limpiar áreas cuando se cierra el modal
|
||||
watch(showModal, (newVal) => {
|
||||
if (!newVal) {
|
||||
examenStore.areas = []
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.no-examen {
|
||||
text-align: center;
|
||||
padding: 40px 0;
|
||||
}
|
||||
|
||||
.examen-info {
|
||||
padding: 16px 0;
|
||||
}
|
||||
|
||||
.actions-container {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
:deep(.ant-descriptions) {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
:deep(.ant-descriptions-item-label) {
|
||||
font-weight: 600;
|
||||
width: 180px;
|
||||
}
|
||||
</style>
|
||||
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,128 @@
|
||||
<template>
|
||||
<a-card class="pagos-card">
|
||||
<template #title>
|
||||
<span>Mis Pagos Realizados</span>
|
||||
</template>
|
||||
|
||||
<!-- Loading -->
|
||||
<a-spin :spinning="loading">
|
||||
|
||||
<!-- Resumen -->
|
||||
<a-alert
|
||||
v-if="!loading"
|
||||
type="info"
|
||||
show-icon
|
||||
class="mb-3"
|
||||
>
|
||||
Total de pagos encontrados: <strong>{{ pagos.length }}</strong>
|
||||
</a-alert>
|
||||
|
||||
<!-- Tabla -->
|
||||
<a-table
|
||||
:dataSource="pagos"
|
||||
:columns="columns"
|
||||
rowKey="key"
|
||||
bordered
|
||||
:pagination="{ pageSize: 5 }"
|
||||
>
|
||||
<!-- Tipo -->
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.key === 'tipo'">
|
||||
<a-tag :color="getColor(record.tipo)">
|
||||
{{ record.tipo.toUpperCase() }}
|
||||
</a-tag>
|
||||
</template>
|
||||
|
||||
<template v-else-if="column.key === 'monto'">
|
||||
S/ {{ Number(record.monto).toFixed(2) }}
|
||||
</template>
|
||||
|
||||
<template v-else-if="column.key === 'fecha_pago'">
|
||||
{{ formatFecha(record.fecha_pago) }}
|
||||
</template>
|
||||
</template>
|
||||
</a-table>
|
||||
|
||||
</a-spin>
|
||||
</a-card>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { message } from 'ant-design-vue'
|
||||
import api from '.././../axiosPostulante'
|
||||
const pagos = ref([])
|
||||
const loading = ref(false)
|
||||
|
||||
const columns = [
|
||||
{
|
||||
title: 'Tipo',
|
||||
dataIndex: 'tipo',
|
||||
key: 'tipo'
|
||||
},
|
||||
{
|
||||
title: 'Código',
|
||||
dataIndex: 'codigo',
|
||||
key: 'codigo'
|
||||
},
|
||||
{
|
||||
title: 'Monto',
|
||||
dataIndex: 'monto',
|
||||
key: 'monto'
|
||||
},
|
||||
{
|
||||
title: 'Fecha de Pago',
|
||||
dataIndex: 'fecha_pago',
|
||||
key: 'fecha_pago'
|
||||
}
|
||||
]
|
||||
|
||||
const obtenerPagos = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const { data } = await api.get('/postulante/pagos')
|
||||
|
||||
if (data.success) {
|
||||
pagos.value = data.pagos.map((pago, index) => ({
|
||||
...pago,
|
||||
key: index
|
||||
}))
|
||||
} else {
|
||||
message.error('No se pudieron obtener los pagos')
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
message.error('Error al cargar pagos')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const getColor = (tipo) => {
|
||||
if (tipo === 'pyto_peru') return 'blue'
|
||||
if (tipo === 'banco_nacion') return 'green'
|
||||
if (tipo === 'caja') return 'orange'
|
||||
return 'default'
|
||||
}
|
||||
|
||||
const formatFecha = (fecha) => {
|
||||
if (!fecha) return '-'
|
||||
return new Date(fecha).toLocaleString('es-PE')
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
obtenerPagos()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.pagos-card {
|
||||
max-width: 1000px;
|
||||
margin: 20px auto;
|
||||
}
|
||||
|
||||
.mb-3 {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
</style>
|
||||
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,664 @@
|
||||
<template>
|
||||
<div class="examen-panel">
|
||||
<!-- Header con información del examen -->
|
||||
<a-card>
|
||||
<div class="examen-header">
|
||||
<div class="header-info">
|
||||
<h2>{{ examenInfo.proceso || 'Proceso no disponible' }}</h2>
|
||||
<p><strong>Área:</strong> {{ examenInfo.area || 'Área no disponible' }}</p>
|
||||
<p><strong>Duración:</strong> {{ examenInfo.duracion }} minutos</p>
|
||||
<p><strong>Intentos:</strong> {{ examenInfo.intentos }} / {{ examenInfo.intentos_maximos }}</p>
|
||||
<p><strong>Tiempo restante:</strong></p>
|
||||
</div>
|
||||
<div class="timer">
|
||||
<a-statistic-countdown
|
||||
:value="timerValue"
|
||||
@finish="finalizarExamenAutomaticamente"
|
||||
format="HH:mm:ss"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</a-card>
|
||||
|
||||
<!-- Preguntas -->
|
||||
<a-card
|
||||
v-for="(pregunta, index) in preguntasTransformadas"
|
||||
:key="pregunta.id"
|
||||
class="pregunta-card"
|
||||
style="margin-top: 16px;"
|
||||
>
|
||||
<template #title>
|
||||
<div class="pregunta-header">
|
||||
<span class="pregunta-numero">Pregunta {{ index + 1 }}</span>
|
||||
<span class="curso-tag">
|
||||
<a-tag color="blue">{{ pregunta.curso }}</a-tag>
|
||||
</span>
|
||||
<a-tag :color="pregunta.respondida ? 'green' : 'orange'">
|
||||
{{ pregunta.estado === 'respondida' ? 'Respondida' : 'Pendiente' }}
|
||||
</a-tag>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Enunciado -->
|
||||
<div class="enunciado" v-html="pregunta.enunciado"></div>
|
||||
|
||||
<!-- Contenido adicional -->
|
||||
<div v-if="pregunta.extra && pregunta.extra !== pregunta.enunciado"
|
||||
class="extra" v-html="pregunta.extra"></div>
|
||||
|
||||
<!-- Opciones múltiples -->
|
||||
<div class="opciones" v-if="pregunta.opciones && pregunta.opciones.length">
|
||||
<a-radio-group
|
||||
v-model:value="pregunta.respuestaSeleccionada"
|
||||
@change="responderPregunta(pregunta)"
|
||||
:disabled="pregunta.estado === 'respondida'"
|
||||
>
|
||||
<a-space direction="vertical" style="width: 100%;">
|
||||
<a-radio
|
||||
v-for="opcion in pregunta.opcionesOrdenadas"
|
||||
:key="opcion.key"
|
||||
:value="opcion.key.toString()" <!-- CONVERTIR A STRING -->
|
||||
class="opcion-radio"
|
||||
>
|
||||
<span class="opcion-key">{{ getLetraOpcion(opcion.key) }}.</span>
|
||||
<span v-html="opcion.texto" class="opcion-texto"></span>
|
||||
</a-radio>
|
||||
</a-space>
|
||||
</a-radio-group>
|
||||
|
||||
<!-- Mostrar respuesta seleccionada -->
|
||||
<div v-if="pregunta.respuestaSeleccionada" class="seleccion-actual">
|
||||
<a-alert
|
||||
:message="`Ha seleccionado: ${getTextoOpcionSeleccionada(pregunta)}`"
|
||||
type="info"
|
||||
show-icon
|
||||
style="margin-top: 12px;"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Pregunta abierta (si no hay opciones) -->
|
||||
<div v-else class="pregunta-abierta">
|
||||
<a-textarea
|
||||
v-model:value="pregunta.respuestaTexto"
|
||||
placeholder="Escriba su respuesta aquí..."
|
||||
:rows="4"
|
||||
:disabled="pregunta.estado === 'respondida'"
|
||||
@blur="responderPreguntaTexto(pregunta)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Información de respuesta correcta (solo para debug) -->
|
||||
<div v-if="debugMode" class="debug-info">
|
||||
<a-alert
|
||||
:message="`Respuesta correcta: ${pregunta.respuesta} (key: ${pregunta.respuestaKey})`"
|
||||
type="warning"
|
||||
show-icon
|
||||
style="margin-top: 12px;"
|
||||
/>
|
||||
</div>
|
||||
</a-card>
|
||||
|
||||
<!-- Resumen y botones -->
|
||||
<a-card style="margin-top: 24px;">
|
||||
<div class="resumen-examen">
|
||||
<h3>Resumen del Examen</h3>
|
||||
<p><strong>Total preguntas:</strong> {{ preguntasTransformadas.length }}</p>
|
||||
<p><strong>Respondidas:</strong> {{ preguntasRespondidas }} de {{ preguntasTransformadas.length }}</p>
|
||||
<p><strong>Progreso:</strong></p>
|
||||
<a-progress :percent="porcentajeCompletado" :stroke-color="{
|
||||
'0%': '#108ee9',
|
||||
'100%': '#87d068',
|
||||
}" />
|
||||
</div>
|
||||
|
||||
<div class="action-buttons" style="margin-top: 24px; text-align: center;">
|
||||
<a-button
|
||||
type="primary"
|
||||
size="large"
|
||||
:loading="finalizando"
|
||||
@click="finalizarExamen"
|
||||
:disabled="!todasRespondidas"
|
||||
>
|
||||
{{ todasRespondidas ? 'Finalizar Examen' : `Responda todas las preguntas (${preguntasTransformadas.length - preguntasRespondidas} pendientes)` }}
|
||||
</a-button>
|
||||
|
||||
<a-button
|
||||
type="default"
|
||||
size="large"
|
||||
style="margin-left: 12px;"
|
||||
@click="guardarYSalir"
|
||||
>
|
||||
Guardar y salir
|
||||
</a-button>
|
||||
</div>
|
||||
</a-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useExamenStore } from '../../store/examen.store'
|
||||
import { message, Modal } from 'ant-design-vue'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const examenStore = useExamenStore()
|
||||
|
||||
const finalizando = ref(false)
|
||||
const debugMode = ref(false) // Cambiar a true para depuración
|
||||
const timerValue = ref(null)
|
||||
|
||||
// Computed properties
|
||||
const examenInfo = computed(() => {
|
||||
if (!examenStore.examenActual) {
|
||||
return {
|
||||
proceso: null,
|
||||
area: null,
|
||||
duracion: 60,
|
||||
intentos: 0,
|
||||
intentos_maximos: 3
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
proceso: examenStore.examenActual?.proceso || 'Proceso no disponible',
|
||||
area: examenStore.examenActual?.area || 'Área no disponible',
|
||||
duracion: examenStore.examenActual?.duracion || 60,
|
||||
intentos: examenStore.examenActual?.intentos || 0,
|
||||
intentos_maximos: examenStore.examenActual?.intentos_maximos || 3
|
||||
}
|
||||
})
|
||||
|
||||
// Transformar preguntas - CORREGIDO para manejar keys numéricas
|
||||
const preguntasTransformadas = computed(() => {
|
||||
if (!examenStore.preguntas || !Array.isArray(examenStore.preguntas)) {
|
||||
return []
|
||||
}
|
||||
|
||||
return examenStore.preguntas.map(pregunta => {
|
||||
// Encontrar la key correcta para la respuesta
|
||||
let respuestaKey = null
|
||||
if (pregunta.opciones && pregunta.respuesta) {
|
||||
const opcionCorrecta = pregunta.opciones.find(op =>
|
||||
op.texto === pregunta.respuesta ||
|
||||
op.key.toString() === pregunta.respuesta.toString()
|
||||
)
|
||||
respuestaKey = opcionCorrecta ? opcionCorrecta.key : null
|
||||
}
|
||||
|
||||
// Ordenar opciones por key
|
||||
const opcionesOrdenadas = pregunta.opciones ?
|
||||
[...pregunta.opciones].sort((a, b) => a.key - b.key) :
|
||||
[]
|
||||
|
||||
return {
|
||||
...pregunta,
|
||||
respuestaKey, // Guardar la key de la respuesta correcta
|
||||
opcionesOrdenadas,
|
||||
// Agregar propiedades reactivas
|
||||
respuestaSeleccionada: null,
|
||||
respuestaTexto: '',
|
||||
// El estado real viene del backend: 'pendiente' o 'respondida'
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
// Helper para convertir key numérico a letra
|
||||
const getLetraOpcion = (key) => {
|
||||
const letras = ['A', 'B', 'C', 'D', 'E', 'F', 'G', 'H']
|
||||
return letras[key] || `Opción ${key}`
|
||||
}
|
||||
|
||||
// Obtener texto de opción seleccionada
|
||||
const getTextoOpcionSeleccionada = (pregunta) => {
|
||||
if (!pregunta.respuestaSeleccionada) return ''
|
||||
const opcion = pregunta.opcionesOrdenadas.find(
|
||||
op => op.key.toString() === pregunta.respuestaSeleccionada.toString()
|
||||
)
|
||||
return opcion ? opcion.texto : 'Opción no encontrada'
|
||||
}
|
||||
|
||||
const preguntasRespondidas = computed(() => {
|
||||
return preguntasTransformadas.value.filter(p =>
|
||||
p.estado === 'respondida' || p.respuestaSeleccionada || p.respuestaTexto
|
||||
).length
|
||||
})
|
||||
|
||||
const porcentajeCompletado = computed(() => {
|
||||
if (preguntasTransformadas.length === 0) return 0
|
||||
return Math.round((preguntasRespondidas.value / preguntasTransformadas.value.length) * 100)
|
||||
})
|
||||
|
||||
const todasRespondidas = computed(() => {
|
||||
return preguntasTransformadas.value.every(p =>
|
||||
p.estado === 'respondida' || p.respuestaSeleccionada || p.respuestaTexto
|
||||
)
|
||||
})
|
||||
|
||||
// Métodos
|
||||
const responderPregunta = async (pregunta) => {
|
||||
if (!pregunta.respuestaSeleccionada) return
|
||||
|
||||
try {
|
||||
// Asegurarse de que enviamos string (no número)
|
||||
const respuestaString = pregunta.respuestaSeleccionada.toString()
|
||||
|
||||
// Encontrar el texto completo de la opción seleccionada
|
||||
const opcionSeleccionada = pregunta.opcionesOrdenadas.find(
|
||||
op => op.key.toString() === respuestaString
|
||||
)
|
||||
|
||||
// Enviar el TEXTO de la opción, no la key
|
||||
const textoRespuesta = opcionSeleccionada ? opcionSeleccionada.texto : respuestaString
|
||||
|
||||
const result = await examenStore.responderPregunta(
|
||||
pregunta.id, // ID de PreguntaAsignada
|
||||
textoRespuesta // Enviar el texto, no la key
|
||||
)
|
||||
|
||||
if (result.success) {
|
||||
// Actualizar estado local
|
||||
pregunta.estado = 'respondida'
|
||||
message.success('Respuesta guardada correctamente')
|
||||
|
||||
// Verificar si es correcta
|
||||
if (result.correcta) {
|
||||
message.info('¡Respuesta correcta!')
|
||||
} else {
|
||||
message.warning('Respuesta incorrecta')
|
||||
}
|
||||
} else {
|
||||
message.error(result.message || 'Error al guardar respuesta')
|
||||
// Revertir selección si falla
|
||||
pregunta.respuestaSeleccionada = null
|
||||
}
|
||||
} catch (error) {
|
||||
message.error('Error al guardar respuesta')
|
||||
console.error('Error:', error)
|
||||
pregunta.respuestaSeleccionada = null
|
||||
}
|
||||
}
|
||||
|
||||
const responderPreguntaTexto = async (pregunta) => {
|
||||
if (!pregunta.respuestaTexto.trim()) return
|
||||
|
||||
try {
|
||||
const result = await examenStore.responderPregunta(
|
||||
pregunta.id,
|
||||
pregunta.respuestaTexto
|
||||
)
|
||||
|
||||
if (result.success) {
|
||||
pregunta.estado = 'respondida'
|
||||
message.success('Respuesta guardada')
|
||||
} else {
|
||||
message.error(result.message || 'Error al guardar respuesta')
|
||||
}
|
||||
} catch (error) {
|
||||
message.error('Error al guardar respuesta')
|
||||
console.error(error)
|
||||
}
|
||||
}
|
||||
|
||||
const finalizarExamen = async () => {
|
||||
if (!todasRespondidas.value) {
|
||||
message.warning(`Por favor responda todas las preguntas. ${preguntasTransformadas.value.length - preguntasRespondidas.value} pendientes.`)
|
||||
return
|
||||
}
|
||||
|
||||
Modal.confirm({
|
||||
title: '¿Está seguro de finalizar el examen?',
|
||||
content: 'Una vez finalizado no podrá modificar sus respuestas.',
|
||||
okText: 'Sí, finalizar',
|
||||
cancelText: 'Cancelar',
|
||||
onOk: async () => {
|
||||
try {
|
||||
finalizando.value = true
|
||||
const result = await examenStore.finalizarExamen(route.params.examenId)
|
||||
|
||||
if (result.success) {
|
||||
message.success('Examen finalizado correctamente')
|
||||
router.push({
|
||||
name: 'panel-resultados',
|
||||
params: { examenId: route.params.examenId }
|
||||
})
|
||||
} else {
|
||||
message.error(result.message || 'Error al finalizar examen')
|
||||
}
|
||||
} catch (error) {
|
||||
message.error('Error al finalizar examen')
|
||||
console.error(error)
|
||||
} finally {
|
||||
finalizando.value = false
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const guardarYSalir = async () => {
|
||||
// Primero guardar todas las respuestas pendientes
|
||||
const preguntasPendientes = preguntasTransformadas.value.filter(p =>
|
||||
(p.respuestaSeleccionada || p.respuestaTexto) && p.estado !== 'respondida'
|
||||
)
|
||||
|
||||
if (preguntasPendientes.length > 0) {
|
||||
Modal.confirm({
|
||||
title: 'Guardar respuestas pendientes',
|
||||
content: `Tiene ${preguntasPendientes.length} respuesta(s) pendientes de guardar. ¿Desea guardarlas antes de salir?`,
|
||||
okText: 'Guardar y salir',
|
||||
cancelText: 'Salir sin guardar',
|
||||
onOk: async () => {
|
||||
try {
|
||||
// Guardar cada respuesta pendiente
|
||||
for (const pregunta of preguntasPendientes) {
|
||||
if (pregunta.respuestaSeleccionada) {
|
||||
await responderPregunta(pregunta)
|
||||
} else if (pregunta.respuestaTexto) {
|
||||
await responderPreguntaTexto(pregunta)
|
||||
}
|
||||
}
|
||||
message.success('Respuestas guardadas')
|
||||
router.push({ name: 'DashboardPostulante' })
|
||||
} catch (error) {
|
||||
message.error('Error al guardar respuestas')
|
||||
}
|
||||
},
|
||||
onCancel: () => {
|
||||
router.push({ name: 'DashboardPostulante' })
|
||||
}
|
||||
})
|
||||
} else {
|
||||
router.push({ name: 'DashboardPostulante' })
|
||||
}
|
||||
}
|
||||
|
||||
const finalizarExamenAutomaticamente = () => {
|
||||
message.warning('Tiempo agotado. El examen se finalizará automáticamente.')
|
||||
finalizarExamen()
|
||||
}
|
||||
|
||||
const calcularTiempoRestante = () => {
|
||||
if (examenStore.examenActual?.hora_inicio && examenInfo.value.duracion) {
|
||||
const horaInicio = new Date(examenStore.examenActual.hora_inicio)
|
||||
const duracionMs = examenInfo.value.duracion * 60 * 1000
|
||||
const tiempoFinal = horaInicio.getTime() + duracionMs
|
||||
timerValue.value = tiempoFinal
|
||||
|
||||
// Verificar si ya se agotó el tiempo
|
||||
if (Date.now() > tiempoFinal) {
|
||||
finalizarExamenAutomaticamente()
|
||||
}
|
||||
} else {
|
||||
// Si no hay hora_inicio, usar duración por defecto desde ahora
|
||||
timerValue.value = Date.now() + (examenInfo.value.duracion * 60 * 1000)
|
||||
}
|
||||
}
|
||||
|
||||
// Lifecycle
|
||||
onMounted(async () => {
|
||||
console.log('=== DATOS DEL EXAMEN ===')
|
||||
|
||||
try {
|
||||
await examenStore.iniciarExamen(route.params.examenId)
|
||||
// ahora las preguntas deberían estar disponibles
|
||||
if (!examenStore.preguntas || examenStore.preguntas.length === 0) {
|
||||
message.error('No se encontraron preguntas para este examen')
|
||||
router.push({ name: 'DashboardPostulante' })
|
||||
return
|
||||
}
|
||||
|
||||
calcularTiempoRestante()
|
||||
setInterval(calcularTiempoRestante, 30000)
|
||||
} catch (err) {
|
||||
console.error(err)
|
||||
message.error('Error al cargar el examen')
|
||||
router.push({ name: 'DashboardPostulante' })
|
||||
}
|
||||
})
|
||||
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* Estilos mejorados */
|
||||
.examen-panel {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.examen-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
flex-wrap: wrap;
|
||||
gap: 20px;
|
||||
margin-bottom: 20px;
|
||||
padding: 16px;
|
||||
background: #f0f9ff;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #d6e4ff;
|
||||
}
|
||||
|
||||
.header-info h2 {
|
||||
margin: 0 0 8px 0;
|
||||
color: #1890ff;
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.header-info p {
|
||||
margin: 4px 0;
|
||||
color: #595959;
|
||||
}
|
||||
|
||||
.timer {
|
||||
text-align: right;
|
||||
min-width: 180px;
|
||||
}
|
||||
|
||||
.timer :deep(.ant-statistic-content) {
|
||||
font-size: 28px;
|
||||
font-weight: bold;
|
||||
color: #fa541c;
|
||||
}
|
||||
|
||||
.pregunta-card {
|
||||
margin-bottom: 24px;
|
||||
border: 1px solid #e8e8e8;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 3px 6px rgba(0, 0, 0, 0.08);
|
||||
transition: box-shadow 0.3s;
|
||||
}
|
||||
|
||||
.pregunta-card:hover {
|
||||
box-shadow: 0 6px 12px rgba(0, 0, 0, 0.12);
|
||||
}
|
||||
|
||||
.pregunta-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
padding-bottom: 12px;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
}
|
||||
|
||||
.pregunta-numero {
|
||||
font-weight: bold;
|
||||
font-size: 18px;
|
||||
color: #1890ff;
|
||||
}
|
||||
|
||||
.curso-tag {
|
||||
flex-grow: 1;
|
||||
margin-left: 12px;
|
||||
}
|
||||
|
||||
.enunciado {
|
||||
font-size: 16px;
|
||||
line-height: 1.7;
|
||||
margin: 20px 0;
|
||||
padding: 20px;
|
||||
background: #fafafa;
|
||||
border-radius: 8px;
|
||||
border-left: 4px solid #1890ff;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
||||
}
|
||||
|
||||
.enunciado :deep(math) {
|
||||
font-size: 1.1em;
|
||||
}
|
||||
|
||||
.extra {
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
margin: 16px 0;
|
||||
padding: 16px;
|
||||
background: #fff7e6;
|
||||
border-radius: 6px;
|
||||
border: 1px solid #ffd591;
|
||||
color: #d46b08;
|
||||
}
|
||||
|
||||
.opciones {
|
||||
margin-top: 24px;
|
||||
}
|
||||
|
||||
.opcion-radio {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 16px 20px;
|
||||
margin: 10px 0;
|
||||
border: 2px solid #d9d9d9;
|
||||
border-radius: 8px;
|
||||
transition: all 0.3s;
|
||||
min-height: 60px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.opcion-radio:hover {
|
||||
border-color: #40a9ff;
|
||||
background: #f0f9ff;
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.opcion-radio:deep(.ant-radio) {
|
||||
margin-right: 12px;
|
||||
}
|
||||
|
||||
.opcion-key {
|
||||
font-weight: bold;
|
||||
color: #1890ff;
|
||||
margin-right: 12px;
|
||||
min-width: 24px;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.opcion-texto {
|
||||
flex: 1;
|
||||
line-height: 1.6;
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.seleccion-actual {
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.debug-info {
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.pregunta-abierta textarea {
|
||||
margin-top: 16px;
|
||||
font-size: 15px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.resumen-examen {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.resumen-examen h3 {
|
||||
margin-bottom: 16px;
|
||||
color: #1890ff;
|
||||
}
|
||||
|
||||
.resumen-examen p {
|
||||
margin: 8px 0;
|
||||
color: #595959;
|
||||
}
|
||||
|
||||
.action-buttons {
|
||||
padding: 24px 0;
|
||||
border-top: 1px solid #f0f0f0;
|
||||
margin-top: 32px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
:deep(.ant-card-head) {
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
background: #fafafa;
|
||||
}
|
||||
|
||||
:deep(.ant-card-body) {
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
/* Estilos para matemáticas */
|
||||
.enunciado :deep(.katex) {
|
||||
font-size: 1.1em;
|
||||
}
|
||||
|
||||
.extra :deep(.katex) {
|
||||
font-size: 1em;
|
||||
}
|
||||
|
||||
/* Responsive */
|
||||
@media (max-width: 768px) {
|
||||
.examen-panel {
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.examen-header {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.timer {
|
||||
text-align: left;
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.pregunta-header {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.curso-tag {
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
.enunciado {
|
||||
padding: 16px;
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.opcion-radio {
|
||||
padding: 12px 16px;
|
||||
}
|
||||
|
||||
.action-buttons {
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.action-buttons button {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@ -0,0 +1,827 @@
|
||||
<template>
|
||||
<div class="resultados-container">
|
||||
<!-- Header de resultados -->
|
||||
<a-card class="resultados-header">
|
||||
<a-result
|
||||
:status="resultadoStatus"
|
||||
:title="resultadoTitulo"
|
||||
:sub-title="resultadoSubtitulo"
|
||||
>
|
||||
<template #extra>
|
||||
<a-space size="large">
|
||||
<a-button type="primary" @click="verDetalles">
|
||||
<eye-outlined />
|
||||
Ver detalles del examen
|
||||
</a-button>
|
||||
<a-button @click="volverADashboard">
|
||||
<home-outlined />
|
||||
Volver al inicio
|
||||
</a-button>
|
||||
</a-space>
|
||||
</template>
|
||||
</a-result>
|
||||
</a-card>
|
||||
|
||||
<!-- Estadísticas principales -->
|
||||
<div class="stats-grid">
|
||||
<a-card class="stat-card">
|
||||
<div class="stat-content">
|
||||
<check-circle-outlined class="stat-icon" style="color: #52c41a;" />
|
||||
<div class="stat-info">
|
||||
<div class="stat-value">{{ examenStore.progreso.correctas }}</div>
|
||||
<div class="stat-label">Respuestas correctas</div>
|
||||
</div>
|
||||
</div>
|
||||
</a-card>
|
||||
|
||||
<a-card class="stat-card">
|
||||
<div class="stat-content">
|
||||
<close-circle-outlined class="stat-icon" style="color: #f5222d;" />
|
||||
<div class="stat-info">
|
||||
<div class="stat-value">{{ respuestasIncorrectas }}</div>
|
||||
<div class="stat-label">Respuestas incorrectas</div>
|
||||
</div>
|
||||
</div>
|
||||
</a-card>
|
||||
|
||||
<a-card class="stat-card">
|
||||
<div class="stat-content">
|
||||
<star-outlined class="stat-icon" style="color: #faad14;" />
|
||||
<div class="stat-info">
|
||||
<div class="stat-value">{{ examenStore.progreso.puntaje_total }}</div>
|
||||
<div class="stat-label">Puntaje total</div>
|
||||
</div>
|
||||
</div>
|
||||
</a-card>
|
||||
|
||||
<a-card class="stat-card">
|
||||
<div class="stat-content">
|
||||
<percentage-outlined class="stat-icon" style="color: #1890ff;" />
|
||||
<div class="stat-info">
|
||||
<div class="stat-value">{{ porcentajeCorrectas }}%</div>
|
||||
<div class="stat-label">Porcentaje de acierto</div>
|
||||
</div>
|
||||
</div>
|
||||
</a-card>
|
||||
</div>
|
||||
|
||||
<!-- Progreso por áreas/temas -->
|
||||
<a-card class="progreso-card" title="Progreso por área">
|
||||
<a-progress
|
||||
:percent="porcentajeCorrectas"
|
||||
:stroke-color="{
|
||||
'0%': '#f5222d',
|
||||
'100%': '#52c41a',
|
||||
}"
|
||||
:show-info="false"
|
||||
/>
|
||||
<div class="progreso-labels">
|
||||
<span>0%</span>
|
||||
<span>50%</span>
|
||||
<span>100%</span>
|
||||
</div>
|
||||
|
||||
<div class="estado-final">
|
||||
<a-tag :color="getEstadoColor()" size="large">
|
||||
{{ getEstadoTexto() }}
|
||||
</a-tag>
|
||||
<span class="recomendacion">
|
||||
{{ recomendacionTexto }}
|
||||
</span>
|
||||
</div>
|
||||
</a-card>
|
||||
|
||||
<!-- Detalle de preguntas -->
|
||||
<a-card class="preguntas-detalle-card" title="Detalle de respuestas">
|
||||
<a-collapse v-model:activeKey="activeKeys" accordion>
|
||||
<a-collapse-panel
|
||||
v-for="pregunta in examenStore.preguntas"
|
||||
:key="pregunta.id"
|
||||
:header="`Pregunta ${pregunta.orden}: ${getResumenPregunta(pregunta)}`"
|
||||
>
|
||||
<!-- Enunciado -->
|
||||
<div class="pregunta-enunciado">
|
||||
<h4>Enunciado:</h4>
|
||||
<div v-html="formatearEnunciado(pregunta.enunciado || '')"></div>
|
||||
|
||||
<!-- Imágenes -->
|
||||
<div v-if="pregunta.imagenes && pregunta.imagenes.length > 0" class="pregunta-images">
|
||||
<a-image
|
||||
v-for="(imagen, idx) in pregunta.imagenes"
|
||||
:key="idx"
|
||||
:src="imagen.url"
|
||||
:alt="`Imagen ${idx + 1}`"
|
||||
class="detalle-image"
|
||||
:preview="true"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Opciones -->
|
||||
<div class="pregunta-opciones">
|
||||
<h4>Opciones:</h4>
|
||||
<div
|
||||
v-for="opcion in pregunta.opciones || []"
|
||||
:key="opcion.key"
|
||||
class="opcion-detalle"
|
||||
:class="{
|
||||
'correcta': esOpcionCorrecta(pregunta, opcion.key),
|
||||
'seleccionada': pregunta.respuesta_usuario == opcion.key,
|
||||
'incorrecta-seleccionada': pregunta.respuesta_usuario == opcion.key && !esOpcionCorrecta(pregunta, opcion.key)
|
||||
}"
|
||||
>
|
||||
<div class="opcion-detalle-content">
|
||||
<div class="opcion-detalle-letra">
|
||||
{{ obtenerLetraOpcion(opcion.key) }}
|
||||
<span v-if="esOpcionCorrecta(pregunta, opcion.key)" class="correct-badge">
|
||||
✓
|
||||
</span>
|
||||
<span v-if="pregunta.respuesta_usuario == opcion.key && !esOpcionCorrecta(pregunta, opcion.key)"
|
||||
class="incorrect-badge">
|
||||
✗
|
||||
</span>
|
||||
</div>
|
||||
<div class="opcion-detalle-texto">
|
||||
{{ opcion.texto }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Resultado de la pregunta -->
|
||||
<div class="pregunta-resultado">
|
||||
<a-alert
|
||||
:type="pregunta.es_correcta ? 'success' : 'error'"
|
||||
:message="pregunta.es_correcta ? '¡Respuesta correcta!' : 'Respuesta incorrecta'"
|
||||
:description="getDescripcionResultado(pregunta)"
|
||||
show-icon
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Explicación (si existe) -->
|
||||
<div v-if="pregunta.explicacion" class="pregunta-explicacion">
|
||||
<h4>Explicación:</h4>
|
||||
<p>{{ pregunta.explicacion }}</p>
|
||||
</div>
|
||||
</a-collapse-panel>
|
||||
</a-collapse>
|
||||
</a-card>
|
||||
|
||||
<!-- Recomendaciones -->
|
||||
<a-card class="recomendaciones-card" title="Recomendaciones">
|
||||
<a-list :data-source="recomendaciones" bordered>
|
||||
<template #renderItem="{ item, index }">
|
||||
<a-list-item>
|
||||
<a-list-item-meta>
|
||||
<template #title>
|
||||
<a-tag :color="item.tipo === 'fortaleza' ? 'green' : 'orange'">
|
||||
{{ item.tipo === 'fortaleza' ? 'Fortaleza' : 'Área a mejorar' }}
|
||||
</a-tag>
|
||||
{{ item.titulo }}
|
||||
</template>
|
||||
<template #description>
|
||||
{{ item.descripcion }}
|
||||
</template>
|
||||
</a-list-item-meta>
|
||||
<div v-if="item.accion">
|
||||
<a-button type="link" size="small">
|
||||
{{ item.accion }}
|
||||
</a-button>
|
||||
</div>
|
||||
</a-list-item>
|
||||
</template>
|
||||
</a-list>
|
||||
</a-card>
|
||||
|
||||
<!-- Acciones finales -->
|
||||
<a-card class="acciones-card">
|
||||
<div class="acciones-content">
|
||||
<a-space size="large">
|
||||
<a-button type="primary" size="large" @click="crearNuevoExamen">
|
||||
<plus-outlined />
|
||||
Realizar nuevo examen
|
||||
</a-button>
|
||||
|
||||
<a-button size="large" @click="imprimirResultados">
|
||||
<printer-outlined />
|
||||
Imprimir resultados
|
||||
</a-button>
|
||||
|
||||
<a-button size="large" @click="compartirResultados">
|
||||
<share-alt-outlined />
|
||||
Compartir resultados
|
||||
</a-button>
|
||||
</a-space>
|
||||
</div>
|
||||
</a-card>
|
||||
|
||||
<!-- Modal para ver detalles completos -->
|
||||
<a-modal
|
||||
v-model:open="modalDetallesVisible"
|
||||
title="Detalles completos del examen"
|
||||
width="800px"
|
||||
:footer="null"
|
||||
>
|
||||
<div class="detalles-completos">
|
||||
<div class="detalle-item">
|
||||
<strong>Proceso:</strong> {{ examenStore.examenActual?.proceso?.nombre || 'No especificado' }}
|
||||
</div>
|
||||
<div class="detalle-item">
|
||||
<strong>Área:</strong> {{ examenStore.examenActual?.area?.nombre || 'No especificado' }}
|
||||
</div>
|
||||
<div class="detalle-item">
|
||||
<strong>Fecha de inicio:</strong> {{ formatFecha(examenStore.examenActual?.hora_inicio) }}
|
||||
</div>
|
||||
<div class="detalle-item">
|
||||
<strong>Duración:</strong> {{ calcularDuracion() }}
|
||||
</div>
|
||||
<div class="detalle-item">
|
||||
<strong>Intentos:</strong> {{ examenStore.intentos }}
|
||||
</div>
|
||||
<div class="detalle-item">
|
||||
<strong>Estado:</strong>
|
||||
<a-tag :color="getEstadoColor()">
|
||||
{{ getEstadoTexto() }}
|
||||
</a-tag>
|
||||
</div>
|
||||
|
||||
<a-divider />
|
||||
|
||||
<h3>Resumen estadístico</h3>
|
||||
<div class="estadisticas-detalle">
|
||||
<a-row :gutter="16">
|
||||
<a-col :span="6" v-for="stat in estadisticasDetalle" :key="stat.label">
|
||||
<div class="estadistica-item">
|
||||
<div class="estadistica-valor">{{ stat.value }}</div>
|
||||
<div class="estadistica-label">{{ stat.label }}</div>
|
||||
</div>
|
||||
</a-col>
|
||||
</a-row>
|
||||
</div>
|
||||
</div>
|
||||
</a-modal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useExamenStore } from '../store/examen.store'
|
||||
import { message } from 'ant-design-vue'
|
||||
import {
|
||||
CheckCircleOutlined,
|
||||
CloseCircleOutlined,
|
||||
StarOutlined,
|
||||
PercentageOutlined,
|
||||
EyeOutlined,
|
||||
HomeOutlined,
|
||||
PlusOutlined,
|
||||
PrinterOutlined,
|
||||
ShareAltOutlined,
|
||||
TrophyOutlined,
|
||||
BulbOutlined
|
||||
} from '@ant-design/icons-vue'
|
||||
|
||||
const router = useRouter()
|
||||
const examenStore = useExamenStore()
|
||||
|
||||
// Estados reactivos
|
||||
const activeKeys = ref([])
|
||||
const modalDetallesVisible = ref(false)
|
||||
|
||||
// Computed properties
|
||||
const respuestasIncorrectas = computed(() => {
|
||||
return examenStore.progreso.respondidas - examenStore.progreso.correctas
|
||||
})
|
||||
|
||||
const porcentajeCorrectas = computed(() => {
|
||||
if (examenStore.progreso.total === 0) return 0
|
||||
return Math.round((examenStore.progreso.correctas / examenStore.progreso.total) * 100)
|
||||
})
|
||||
|
||||
const resultadoStatus = computed(() => {
|
||||
if (porcentajeCorrectas.value >= 80) return 'success'
|
||||
if (porcentajeCorrectas.value >= 60) return 'warning'
|
||||
return 'error'
|
||||
})
|
||||
|
||||
const resultadoTitulo = computed(() => {
|
||||
if (porcentajeCorrectas.value >= 80) return '¡Excelente resultado!'
|
||||
if (porcentajeCorrectas.value >= 60) return 'Buen trabajo'
|
||||
return 'Necesitas practicar más'
|
||||
})
|
||||
|
||||
const resultadoSubtitulo = computed(() => {
|
||||
return `Obtuviste ${examenStore.progreso.correctas} de ${examenStore.progreso.total} respuestas correctas (${porcentajeCorrectas.value}%)`
|
||||
})
|
||||
|
||||
const recomendacionTexto = computed(() => {
|
||||
if (porcentajeCorrectas.value >= 80) {
|
||||
return 'Tu nivel es excelente. Sigue manteniendo el buen trabajo.'
|
||||
} else if (porcentajeCorrectas.value >= 60) {
|
||||
return 'Tienes un buen nivel, pero hay áreas que puedes mejorar.'
|
||||
} else {
|
||||
return 'Recomendamos repasar los temas antes de intentar nuevamente.'
|
||||
}
|
||||
})
|
||||
|
||||
const recomendaciones = computed(() => {
|
||||
const recomendacionesList = []
|
||||
|
||||
if (porcentajeCorrectas.value >= 80) {
|
||||
recomendacionesList.push({
|
||||
tipo: 'fortaleza',
|
||||
titulo: 'Excelente comprensión',
|
||||
descripcion: 'Demuestras un dominio sólido de los temas evaluados.',
|
||||
accion: 'Ver temas avanzados'
|
||||
})
|
||||
} else if (porcentajeCorrectas.value >= 60) {
|
||||
recomendacionesList.push({
|
||||
tipo: 'fortaleza',
|
||||
titulo: 'Bases sólidas',
|
||||
descripcion: 'Tienes buenos fundamentos en la mayoría de temas.',
|
||||
accion: 'Profundizar conocimientos'
|
||||
})
|
||||
recomendacionesList.push({
|
||||
tipo: 'mejora',
|
||||
titulo: 'Áreas específicas',
|
||||
descripcion: 'Algunos temas requieren más atención.',
|
||||
accion: 'Repasar temas'
|
||||
})
|
||||
} else {
|
||||
recomendacionesList.push({
|
||||
tipo: 'mejora',
|
||||
titulo: 'Revisión general',
|
||||
descripcion: 'Recomendamos repasar todos los temas del área.',
|
||||
accion: 'Iniciar repaso'
|
||||
})
|
||||
}
|
||||
|
||||
// Recomendaciones basadas en respuestas incorrectas
|
||||
if (respuestasIncorrectas.value > examenStore.progreso.total / 2) {
|
||||
recomendacionesList.push({
|
||||
tipo: 'mejora',
|
||||
titulo: 'Tiempo de estudio',
|
||||
descripcion: 'Dedica más tiempo al estudio antes de evaluarte nuevamente.',
|
||||
accion: 'Programar estudio'
|
||||
})
|
||||
}
|
||||
|
||||
return recomendacionesList
|
||||
})
|
||||
|
||||
const estadisticasDetalle = computed(() => [
|
||||
{ label: 'Total preguntas', value: examenStore.progreso.total },
|
||||
{ label: 'Respondidas', value: examenStore.progreso.respondidas },
|
||||
{ label: 'Correctas', value: examenStore.progreso.correctas },
|
||||
{ label: 'Incorrectas', value: respuestasIncorrectas.value },
|
||||
{ label: 'Puntaje máximo', value: examenStore.progreso.total * 10 }, // Asumiendo 10 puntos por pregunta
|
||||
{ label: 'Puntaje obtenido', value: examenStore.progreso.puntaje_total },
|
||||
{ label: 'Porcentaje', value: `${porcentajeCorrectas.value}%` },
|
||||
{ label: 'Tasa de acierto', value: `${Math.round((examenStore.progreso.correctas / examenStore.progreso.respondidas) * 100) || 0}%` }
|
||||
])
|
||||
|
||||
// Funciones de utilidad
|
||||
const formatearEnunciado = (enunciado) => {
|
||||
if (!enunciado) return ''
|
||||
return enunciado.replace(/\n/g, '<br>')
|
||||
}
|
||||
|
||||
const obtenerLetraOpcion = (key) => {
|
||||
const letras = ['A', 'B', 'C', 'D', 'E', 'F']
|
||||
return letras[key] || `Opción ${key + 1}`
|
||||
}
|
||||
|
||||
const getResumenPregunta = (pregunta) => {
|
||||
const estado = pregunta.es_correcta ? '✓ Correcta' : '✗ Incorrecta'
|
||||
return `${estado} - Puntaje: ${pregunta.puntaje_obtenido || 0}`
|
||||
}
|
||||
|
||||
const esOpcionCorrecta = (pregunta, opcionKey) => {
|
||||
// En un sistema real, esto vendría del backend
|
||||
// Por ahora, asumimos que la primera opción es correcta (para demo)
|
||||
return opcionKey == 0
|
||||
}
|
||||
|
||||
const getDescripcionResultado = (pregunta) => {
|
||||
if (pregunta.es_correcta) {
|
||||
return `Obtuviste ${pregunta.puntaje_obtenido || 0} puntos por esta respuesta correcta.`
|
||||
} else {
|
||||
return `Tu respuesta fue: ${obtenerLetraOpcion(parseInt(pregunta.respuesta_usuario))}. La respuesta correcta era: ${obtenerLetraOpcion(0)}.`
|
||||
}
|
||||
}
|
||||
|
||||
const formatFecha = (fecha) => {
|
||||
if (!fecha) return 'No disponible'
|
||||
const date = new Date(fecha)
|
||||
return date.toLocaleDateString() + ' ' + date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
|
||||
}
|
||||
|
||||
const calcularDuracion = () => {
|
||||
if (!examenStore.examenActual?.hora_inicio) return 'No disponible'
|
||||
|
||||
const inicio = new Date(examenStore.examenActual.hora_inicio)
|
||||
const fin = new Date() // Asumimos que terminó ahora
|
||||
const duracionMs = fin - inicio
|
||||
|
||||
const minutos = Math.floor(duracionMs / (1000 * 60))
|
||||
const segundos = Math.floor((duracionMs % (1000 * 60)) / 1000)
|
||||
|
||||
return `${minutos} min ${segundos} seg`
|
||||
}
|
||||
|
||||
const getEstadoColor = () => {
|
||||
if (porcentajeCorrectas.value >= 80) return 'green'
|
||||
if (porcentajeCorrectas.value >= 60) return 'blue'
|
||||
return 'red'
|
||||
}
|
||||
|
||||
const getEstadoTexto = () => {
|
||||
if (porcentajeCorrectas.value >= 80) return 'Excelente'
|
||||
if (porcentajeCorrectas.value >= 60) return 'Aprobado'
|
||||
return 'Reprobado'
|
||||
}
|
||||
|
||||
// Funciones de acciones
|
||||
const verDetalles = () => {
|
||||
modalDetallesVisible.value = true
|
||||
}
|
||||
|
||||
const volverADashboard = () => {
|
||||
router.push('/dashboard')
|
||||
}
|
||||
|
||||
const crearNuevoExamen = () => {
|
||||
// Resetear el examen actual y volver al dashboard
|
||||
examenStore.resetExamen()
|
||||
examenStore.resetConfiguracion()
|
||||
router.push('/dashboard')
|
||||
}
|
||||
|
||||
const imprimirResultados = () => {
|
||||
window.print()
|
||||
}
|
||||
|
||||
const compartirResultados = () => {
|
||||
const resultados = `Mis resultados del examen: ${porcentajeCorrectas.value}% de acierto (${examenStore.progreso.correctas}/${examenStore.progreso.total})`
|
||||
|
||||
if (navigator.share) {
|
||||
navigator.share({
|
||||
title: 'Resultados de mi examen',
|
||||
text: resultados,
|
||||
url: window.location.href
|
||||
})
|
||||
} else {
|
||||
// Fallback: copiar al portapapeles
|
||||
navigator.clipboard.writeText(resultados)
|
||||
.then(() => {
|
||||
message.success('Resultados copiados al portapapeles')
|
||||
})
|
||||
.catch(() => {
|
||||
message.info(resultados)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Inicialización
|
||||
onMounted(() => {
|
||||
// Verificar que tenemos resultados
|
||||
if (!examenStore.tieneExamenActual || !examenStore.tienePreguntasGeneradas) {
|
||||
message.warning('No hay resultados de examen disponibles')
|
||||
router.push('/dashboard')
|
||||
return
|
||||
}
|
||||
|
||||
// Expandir la primera pregunta por defecto
|
||||
if (examenStore.preguntas.length > 0) {
|
||||
activeKeys.value = [examenStore.preguntas[0].id]
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.resultados-container {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.resultados-header {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.stats-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
|
||||
gap: 16px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
.stat-card:hover {
|
||||
transform: translateY(-4px);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
.stat-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.stat-icon {
|
||||
font-size: 32px;
|
||||
}
|
||||
|
||||
.stat-info {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 28px;
|
||||
font-weight: bold;
|
||||
color: #333;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 14px;
|
||||
color: #666;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.progreso-card {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.progreso-labels {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin-top: 8px;
|
||||
font-size: 12px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.estado-final {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-top: 16px;
|
||||
padding-top: 16px;
|
||||
border-top: 1px solid #f0f0f0;
|
||||
}
|
||||
|
||||
.recomendacion {
|
||||
font-style: italic;
|
||||
color: #666;
|
||||
max-width: 400px;
|
||||
}
|
||||
|
||||
.preguntas-detalle-card {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.pregunta-enunciado {
|
||||
margin-bottom: 20px;
|
||||
padding: 16px;
|
||||
background: #fafafa;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.pregunta-enunciado h4 {
|
||||
margin-bottom: 8px;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.pregunta-images {
|
||||
margin-top: 16px;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.detalle-image {
|
||||
max-width: 200px;
|
||||
border-radius: 4px;
|
||||
border: 1px solid #f0f0f0;
|
||||
}
|
||||
|
||||
.pregunta-opciones {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.pregunta-opciones h4 {
|
||||
margin-bottom: 12px;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.opcion-detalle {
|
||||
padding: 12px;
|
||||
margin-bottom: 8px;
|
||||
border: 1px solid #d9d9d9;
|
||||
border-radius: 4px;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
.opcion-detalle.correcta {
|
||||
border-color: #52c41a;
|
||||
background-color: #f6ffed;
|
||||
}
|
||||
|
||||
.opcion-detalle.seleccionada {
|
||||
border-color: #1890ff;
|
||||
background-color: #e6f7ff;
|
||||
}
|
||||
|
||||
.opcion-detalle.incorrecta-seleccionada {
|
||||
border-color: #f5222d;
|
||||
background-color: #fff1f0;
|
||||
}
|
||||
|
||||
.opcion-detalle-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.opcion-detalle-letra {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background-color: #f0f0f0;
|
||||
border-radius: 50%;
|
||||
font-weight: bold;
|
||||
font-size: 12px;
|
||||
flex-shrink: 0;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.opcion-detalle.correcta .opcion-detalle-letra {
|
||||
background-color: #52c41a;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.opcion-detalle.seleccionada .opcion-detalle-letra {
|
||||
background-color: #1890ff;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.opcion-detalle.incorrecta-seleccionada .opcion-detalle-letra {
|
||||
background-color: #f5222d;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.correct-badge, .incorrect-badge {
|
||||
position: absolute;
|
||||
top: -6px;
|
||||
right: -6px;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 50%;
|
||||
font-size: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.correct-badge {
|
||||
background-color: #52c41a;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.incorrect-badge {
|
||||
background-color: #f5222d;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.opcion-detalle-texto {
|
||||
flex: 1;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.pregunta-resultado {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.pregunta-explicacion {
|
||||
padding: 16px;
|
||||
background: #f6ffed;
|
||||
border-radius: 4px;
|
||||
border-left: 3px solid #52c41a;
|
||||
}
|
||||
|
||||
.pregunta-explicacion h4 {
|
||||
margin-bottom: 8px;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.recomendaciones-card {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.acciones-card {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.acciones-content {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.detalles-completos {
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.detalle-item {
|
||||
margin-bottom: 12px;
|
||||
padding: 8px 0;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
}
|
||||
|
||||
.detalle-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.estadisticas-detalle {
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.estadistica-item {
|
||||
text-align: center;
|
||||
padding: 16px;
|
||||
background: #fafafa;
|
||||
border-radius: 4px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.estadistica-valor {
|
||||
font-size: 24px;
|
||||
font-weight: bold;
|
||||
color: #1890ff;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.estadistica-label {
|
||||
font-size: 12px;
|
||||
color: #666;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
/* Estilos para impresión */
|
||||
@media print {
|
||||
.resultados-container {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.acciones-card,
|
||||
.ant-space,
|
||||
.ant-btn,
|
||||
.ant-collapse-arrow {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.ant-collapse-content {
|
||||
display: block !important;
|
||||
height: auto !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* Responsive */
|
||||
@media (max-width: 768px) {
|
||||
.stats-grid {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
|
||||
.estado-final {
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.acciones-content .ant-space {
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.acciones-content .ant-btn {
|
||||
width: 100%;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.stats-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.stat-content {
|
||||
justify-content: center;
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
Loading…
Reference in New Issue