Merge branch 'main' into docker-prod
commit
195d50de7f
@ -0,0 +1,109 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Administracion;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Calificacion;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Validator;
|
||||
|
||||
class CalificacionController extends Controller
|
||||
{
|
||||
// ✅ Listar todas
|
||||
public function index()
|
||||
{
|
||||
$calificaciones = Calificacion::all();
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'data' => $calificaciones
|
||||
]);
|
||||
}
|
||||
|
||||
// ✅ Guardar nueva
|
||||
public function store(Request $request)
|
||||
{
|
||||
$request->validate([
|
||||
'nombre' => 'required|string|max:255',
|
||||
'puntos_correcta' => 'required|numeric',
|
||||
'puntos_incorrecta' => 'required|numeric',
|
||||
'puntos_nula' => 'required|numeric',
|
||||
'puntaje_maximo' => 'required|numeric',
|
||||
]);
|
||||
|
||||
$calificacion = Calificacion::create($request->all());
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => 'Calificación creada correctamente',
|
||||
'data' => $calificacion
|
||||
]);
|
||||
}
|
||||
|
||||
// ✅ Mostrar una
|
||||
public function show($id)
|
||||
{
|
||||
$calificacion = Calificacion::find($id);
|
||||
|
||||
if (!$calificacion) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => 'No encontrada'
|
||||
], 404);
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'data' => $calificacion
|
||||
]);
|
||||
}
|
||||
|
||||
// ✅ Actualizar
|
||||
public function update(Request $request, $id)
|
||||
{
|
||||
$calificacion = Calificacion::find($id);
|
||||
|
||||
if (!$calificacion) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => 'No encontrada'
|
||||
], 404);
|
||||
}
|
||||
|
||||
$request->validate([
|
||||
'nombre' => 'required|string|max:255',
|
||||
'puntos_correcta' => 'required|numeric',
|
||||
'puntos_incorrecta' => 'required|numeric',
|
||||
'puntos_nula' => 'required|numeric',
|
||||
'puntaje_maximo' => 'required|numeric',
|
||||
]);
|
||||
|
||||
$calificacion->update($request->all());
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => 'Calificación actualizada correctamente',
|
||||
'data' => $calificacion
|
||||
]);
|
||||
}
|
||||
|
||||
// ✅ Eliminar
|
||||
public function destroy($id)
|
||||
{
|
||||
$calificacion = Calificacion::find($id);
|
||||
|
||||
if (!$calificacion) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => 'No encontrada'
|
||||
], 404);
|
||||
}
|
||||
|
||||
$calificacion->delete();
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => 'Calificación eliminada correctamente'
|
||||
]);
|
||||
}
|
||||
}
|
||||
@ -1,465 +0,0 @@
|
||||
<?php
|
||||
namespace App\Http\Controllers\Administracion;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Academia;
|
||||
use App\Models\User;
|
||||
use App\Models\Examen;
|
||||
use App\Models\Pregunta;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Validator;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Str;
|
||||
use Carbon\Carbon;
|
||||
|
||||
class ExamenesController extends Controller
|
||||
{
|
||||
|
||||
public function getExamenes(Request $request)
|
||||
{
|
||||
try {
|
||||
$user = auth()->user();
|
||||
|
||||
if (!$user->hasRole('administrador')) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => 'No autorizado'
|
||||
], 403);
|
||||
}
|
||||
|
||||
$academia = Academia::where('admin_academia_id', $user->id)->first();
|
||||
|
||||
if (!$academia) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => 'Academia no encontrada'
|
||||
], 404);
|
||||
}
|
||||
|
||||
$query = $academia->examenes()
|
||||
->withCount(['preguntas', 'intentos' => function($query) {
|
||||
$query->where('estado', 'finalizado');
|
||||
}])
|
||||
->latest();
|
||||
|
||||
if ($request->has('search')) {
|
||||
$search = $request->search;
|
||||
$query->where('titulo', 'like', "%{$search}%");
|
||||
}
|
||||
|
||||
if ($request->has('publicado')) {
|
||||
$query->where('publicado', $request->publicado);
|
||||
}
|
||||
|
||||
if ($request->has('tipo')) {
|
||||
$query->where('tipo', $request->tipo);
|
||||
}
|
||||
|
||||
$examenes = $query->paginate(15);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'data' => $examenes
|
||||
]);
|
||||
|
||||
} catch (\Exception $e) {
|
||||
Log::error('Error obteniendo exámenes', [
|
||||
'error' => $e->getMessage()
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => 'Error al cargar los exámenes'
|
||||
], 500);
|
||||
}
|
||||
}
|
||||
|
||||
public function crearExamen(Request $request)
|
||||
{
|
||||
try {
|
||||
$user = auth()->user();
|
||||
|
||||
if (!$user->hasRole('administrador')) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => 'No autorizado'
|
||||
], 403);
|
||||
}
|
||||
|
||||
$academia = Academia::where('admin_academia_id', $user->id)->first();
|
||||
|
||||
if (!$academia) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => 'Academia no encontrada'
|
||||
], 404);
|
||||
}
|
||||
|
||||
$validator = Validator::make($request->all(), [
|
||||
'titulo' => 'required|string|max:255',
|
||||
'descripcion' => 'nullable|string',
|
||||
'tipo' => 'required|in:practica,simulacro,evaluacion',
|
||||
'dificultad' => 'required|in:facil,medio,dificil,avanzado',
|
||||
|
||||
'duracion_minutos' => 'required|integer|min:1|max:480',
|
||||
'intentos_permitidos' => 'required|integer|min:1|max:10',
|
||||
'puntaje_minimo' => 'required|numeric|min:0|max:100',
|
||||
'preguntas_aleatorias' => 'boolean',
|
||||
'mostrar_resultados' => 'boolean',
|
||||
'mostrar_respuestas' => 'boolean',
|
||||
'publicado' => 'boolean',
|
||||
'fecha_inicio' => 'nullable|date',
|
||||
'fecha_fin' => 'nullable|date|after_or_equal:fecha_inicio',
|
||||
'configuracion' => 'nullable|array'
|
||||
]);
|
||||
|
||||
if ($validator->fails()) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'errors' => $validator->errors()
|
||||
], 422);
|
||||
}
|
||||
|
||||
DB::beginTransaction();
|
||||
|
||||
$examen = Examen::create([
|
||||
'academia_id' => $academia->id,
|
||||
'titulo' => $request->titulo,
|
||||
'descripcion' => $request->descripcion,
|
||||
'tipo' => $request->tipo,
|
||||
'dificultad' => $request->dificultad,
|
||||
'duracion_minutos' => $request->duracion_minutos,
|
||||
'intentos_permitidos' => $request->intentos_permitidos,
|
||||
'puntaje_minimo' => $request->puntaje_minimo,
|
||||
|
||||
'preguntas_aleatorias' => $request->preguntas_aleatorias ?? 0,
|
||||
|
||||
'mezclar_opciones' => $request->mezclar_opciones ?? true,
|
||||
'mostrar_resultados' => $request->mostrar_resultados ?? true,
|
||||
'mostrar_respuestas' => $request->mostrar_respuestas ?? false,
|
||||
'mostrar_explicaciones' => $request->mostrar_explicaciones ?? false,
|
||||
|
||||
'activar_timer' => $request->activar_timer ?? true,
|
||||
'permitir_navegacion' => $request->permitir_navegacion ?? true,
|
||||
'permitir_revisar' => $request->permitir_revisar ?? true,
|
||||
|
||||
'publicado' => $request->publicado ?? false,
|
||||
'fecha_inicio' => $request->fecha_inicio,
|
||||
'fecha_fin' => $request->fecha_fin,
|
||||
'orden' => $request->orden ?? 1,
|
||||
|
||||
'configuracion' => $request->configuracion ?? []
|
||||
]);
|
||||
|
||||
DB::commit();
|
||||
|
||||
Log::info('Examen creado por admin', [
|
||||
'academia_id' => $academia->id,
|
||||
'examen_id' => $examen->id,
|
||||
'admin_id' => $user->id
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => 'Examen creado exitosamente',
|
||||
'data' => $examen
|
||||
], 201);
|
||||
|
||||
} catch (\Exception $e) {
|
||||
DB::rollBack();
|
||||
|
||||
Log::error('Error creando examen', [
|
||||
'error' => $e->getMessage()
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => 'Error al crear el examen'
|
||||
], 500);
|
||||
}
|
||||
}
|
||||
|
||||
public function getExamen($examenId)
|
||||
{
|
||||
try {
|
||||
$user = auth()->user();
|
||||
|
||||
if (!$user->hasRole('administrador')) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => 'No autorizado'
|
||||
], 403);
|
||||
}
|
||||
|
||||
$academia = Academia::where('admin_academia_id', $user->id)->first();
|
||||
|
||||
if (!$academia) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => 'Academia no encontrada'
|
||||
], 404);
|
||||
}
|
||||
|
||||
$examen = Examen::where('academia_id', $academia->id)
|
||||
->with(['preguntas'])
|
||||
->find($examenId);
|
||||
|
||||
if (!$examen) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => 'Examen no encontrado'
|
||||
], 404);
|
||||
}
|
||||
|
||||
$estadisticas = DB::table('intentos_examen')
|
||||
->where('examen_id', $examenId)
|
||||
->where('estado', 'finalizado')
|
||||
->selectRaw('COUNT(*) as total_intentos')
|
||||
->selectRaw('AVG(porcentaje) as promedio')
|
||||
->selectRaw('SUM(CASE WHEN aprobado = 1 THEN 1 ELSE 0 END) as aprobados')
|
||||
->first();
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'data' => [
|
||||
'examen' => $examen,
|
||||
'estadisticas' => $estadisticas
|
||||
]
|
||||
]);
|
||||
|
||||
} catch (\Exception $e) {
|
||||
Log::error('Error obteniendo examen', [
|
||||
'error' => $e->getMessage()
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => 'Error al cargar el examen'
|
||||
], 500);
|
||||
}
|
||||
}
|
||||
|
||||
public function actualizarExamen(Request $request, $examenId)
|
||||
{
|
||||
try {
|
||||
$user = auth()->user();
|
||||
|
||||
if (!$user->hasRole('administrador')) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => 'No autorizado'
|
||||
], 403);
|
||||
}
|
||||
|
||||
$academia = Academia::where('admin_academia_id', $user->id)->first();
|
||||
|
||||
if (!$academia) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => 'Academia no encontrada'
|
||||
], 404);
|
||||
}
|
||||
|
||||
$examen = Examen::where('academia_id', $academia->id)->find($examenId);
|
||||
|
||||
if (!$examen) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => 'Examen no encontrado'
|
||||
], 404);
|
||||
}
|
||||
|
||||
$validator = Validator::make($request->all(), [
|
||||
'titulo' => 'sometimes|string|max:255',
|
||||
'descripcion' => 'nullable|string',
|
||||
'tipo' => 'sometimes|in:practica,simulacro,evaluacion',
|
||||
'duracion_minutos' => 'sometimes|integer|min:1|max:480',
|
||||
'intentos_permitidos' => 'sometimes|integer|min:1|max:10',
|
||||
'puntaje_minimo' => 'sometimes|numeric|min:0|max:100',
|
||||
'preguntas_aleatorias' => 'boolean',
|
||||
'mostrar_resultados' => 'boolean',
|
||||
'mostrar_respuestas' => 'boolean',
|
||||
'publicado' => 'boolean',
|
||||
'fecha_inicio' => 'nullable|date',
|
||||
'fecha_fin' => 'nullable|date|after_or_equal:fecha_inicio',
|
||||
'configuracion' => 'nullable|array'
|
||||
]);
|
||||
|
||||
if ($validator->fails()) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'errors' => $validator->errors()
|
||||
], 422);
|
||||
}
|
||||
|
||||
$examen->update($request->only([
|
||||
'titulo', 'descripcion', 'tipo', 'duracion_minutos', 'intentos_permitidos',
|
||||
'puntaje_minimo', 'preguntas_aleatorias', 'mostrar_resultados',
|
||||
'mostrar_respuestas', 'publicado', 'fecha_inicio', 'fecha_fin', 'configuracion'
|
||||
]));
|
||||
|
||||
Log::info('Examen actualizado por admin', [
|
||||
'academia_id' => $academia->id,
|
||||
'examen_id' => $examen->id,
|
||||
'admin_id' => $user->id
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => 'Examen actualizado exitosamente',
|
||||
'data' => $examen
|
||||
]);
|
||||
|
||||
} catch (\Exception $e) {
|
||||
Log::error('Error actualizando examen', [
|
||||
'error' => $e->getMessage()
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => 'Error al actualizar el examen'
|
||||
], 500);
|
||||
}
|
||||
}
|
||||
|
||||
public function eliminarExamen($examenId)
|
||||
{
|
||||
try {
|
||||
$user = auth()->user();
|
||||
|
||||
if (!$user->hasRole('administrador')) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => 'No autorizado'
|
||||
], 403);
|
||||
}
|
||||
|
||||
$academia = Academia::where('admin_academia_id', $user->id)->first();
|
||||
|
||||
if (!$academia) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => 'Academia no encontrada'
|
||||
], 404);
|
||||
}
|
||||
|
||||
$examen = Examen::where('academia_id', $academia->id)->find($examenId);
|
||||
|
||||
if (!$examen) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => 'Examen no encontrado'
|
||||
], 404);
|
||||
}
|
||||
|
||||
$tieneIntentos = $examen->intentos()->exists();
|
||||
|
||||
if ($tieneIntentos) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => 'No se puede eliminar un examen con intentos realizados'
|
||||
], 400);
|
||||
}
|
||||
|
||||
$examen->delete();
|
||||
|
||||
Log::info('Examen eliminado por admin', [
|
||||
'academia_id' => $academia->id,
|
||||
'examen_id' => $examenId,
|
||||
'admin_id' => $user->id
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => 'Examen eliminado exitosamente'
|
||||
]);
|
||||
|
||||
} catch (\Exception $e) {
|
||||
Log::error('Error eliminando examen', [
|
||||
'error' => $e->getMessage()
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => 'Error al eliminar el examen'
|
||||
], 500);
|
||||
}
|
||||
}
|
||||
|
||||
public function getResultadosExamen($examenId)
|
||||
{
|
||||
try {
|
||||
$user = auth()->user();
|
||||
|
||||
if (!$user->hasRole('administrador')) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => 'No autorizado'
|
||||
], 403);
|
||||
}
|
||||
|
||||
$academia = Academia::where('admin_academia_id', $user->id)->first();
|
||||
|
||||
if (!$academia) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => 'Academia no encontrada'
|
||||
], 404);
|
||||
}
|
||||
|
||||
$examen = Examen::where('academia_id', $academia->id)->find($examenId);
|
||||
|
||||
if (!$examen) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => 'Examen no encontrado'
|
||||
], 404);
|
||||
}
|
||||
|
||||
$resultados = DB::table('intentos_examen')
|
||||
->join('users', 'intentos_examen.user_id', '=', 'users.id')
|
||||
->where('intentos_examen.examen_id', $examenId)
|
||||
->where('intentos_examen.estado', 'finalizado')
|
||||
->select(
|
||||
'users.id as estudiante_id',
|
||||
'users.name as estudiante_nombre',
|
||||
'users.email as estudiante_email',
|
||||
'intentos_examen.numero_intento',
|
||||
'intentos_examen.porcentaje',
|
||||
'intentos_examen.aprobado',
|
||||
'intentos_examen.tiempo_utilizado',
|
||||
'intentos_examen.finalizado_en'
|
||||
)
|
||||
->orderBy('intentos_examen.porcentaje', 'desc')
|
||||
->get();
|
||||
|
||||
$estadisticas = [
|
||||
'total_estudiantes' => $resultados->groupBy('estudiante_id')->count(),
|
||||
'promedio' => $resultados->avg('porcentaje'),
|
||||
'aprobados' => $resultados->where('aprobado', true)->count(),
|
||||
'reprobados' => $resultados->where('aprobado', false)->count(),
|
||||
'tiempo_promedio' => $resultados->avg('tiempo_utilizado')
|
||||
];
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'data' => [
|
||||
'resultados' => $resultados,
|
||||
'estadisticas' => $estadisticas
|
||||
]
|
||||
]);
|
||||
|
||||
} catch (\Exception $e) {
|
||||
Log::error('Error obteniendo resultados', [
|
||||
'error' => $e->getMessage()
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => 'Error al cargar los resultados'
|
||||
], 500);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@ -0,0 +1,209 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Administracion;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Noticia;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class NoticiaController extends Controller
|
||||
{
|
||||
// GET /api/noticias
|
||||
public function index(Request $request)
|
||||
{
|
||||
$perPage = (int) $request->get('per_page', 9);
|
||||
|
||||
$query = Noticia::query();
|
||||
|
||||
if ($request->filled('publicado')) {
|
||||
$query->where('publicado', $request->boolean('publicado'));
|
||||
}
|
||||
|
||||
if ($request->filled('categoria')) {
|
||||
$query->where('categoria', (string) $request->get('categoria'));
|
||||
}
|
||||
|
||||
if ($request->filled('q')) {
|
||||
$q = trim((string) $request->get('q'));
|
||||
$query->where(function ($sub) use ($q) {
|
||||
$sub->where('titulo', 'like', "%{$q}%")
|
||||
->orWhere('descripcion_corta', 'like', "%{$q}%");
|
||||
});
|
||||
}
|
||||
|
||||
$data = $query
|
||||
->orderByDesc('destacado')
|
||||
->orderByDesc('fecha_publicacion')
|
||||
->orderByDesc('orden')
|
||||
->orderByDesc('id')
|
||||
->paginate($perPage);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'data' => $data->items(), // incluye imagen_url por el accessor/appends
|
||||
'meta' => [
|
||||
'current_page' => $data->currentPage(),
|
||||
'last_page' => $data->lastPage(),
|
||||
'per_page' => $data->perPage(),
|
||||
'total' => $data->total(),
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
// GET /api/noticias/{noticia}
|
||||
public function show(Noticia $noticia)
|
||||
{
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'data' => $noticia, // incluye imagen_url por el accessor/appends
|
||||
]);
|
||||
}
|
||||
|
||||
// GET /api/noticias-publicas/{noticia} (o la ruta que uses)
|
||||
public function showPublic(Noticia $noticia)
|
||||
{
|
||||
abort_unless($noticia->publicado, 404);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'data' => $noticia,
|
||||
]);
|
||||
}
|
||||
|
||||
// POST /api/noticias (multipart/form-data si viene imagen)
|
||||
public function store(Request $request)
|
||||
{
|
||||
$data = $request->validate([
|
||||
'titulo' => ['required', 'string', 'max:220'],
|
||||
'slug' => ['nullable', 'string', 'max:260', 'unique:noticias,slug'],
|
||||
'descripcion_corta' => ['nullable', 'string', 'max:500'],
|
||||
'contenido' => ['nullable', 'string'],
|
||||
'categoria' => ['nullable', 'string', 'max:80'],
|
||||
'tag_color' => ['nullable', 'string', 'max:30'],
|
||||
|
||||
// ✅ dos formas de imagen
|
||||
'imagen' => ['nullable', 'file', 'mimes:jpg,jpeg,png,webp', 'max:4096'],
|
||||
'imagen_url' => ['nullable', 'url', 'max:600'],
|
||||
|
||||
'link_url' => ['nullable', 'url', 'max:600'],
|
||||
'link_texto' => ['nullable', 'string', 'max:120'],
|
||||
'fecha_publicacion' => ['nullable', 'date'],
|
||||
'publicado' => ['nullable', 'boolean'],
|
||||
'destacado' => ['nullable', 'boolean'],
|
||||
'orden' => ['nullable', 'integer'],
|
||||
]);
|
||||
|
||||
// slug por defecto (igual tu modelo lo genera, pero aquí lo dejamos por consistencia)
|
||||
if (empty($data['slug'])) {
|
||||
$data['slug'] = Str::slug($data['titulo']);
|
||||
}
|
||||
|
||||
// si viene archivo, manda a storage y prioriza archivo
|
||||
if ($request->hasFile('imagen')) {
|
||||
$path = $request->file('imagen')->store('noticias', 'public');
|
||||
$data['imagen_path'] = $path;
|
||||
$data['imagen_url'] = null; // ✅ evita conflicto con url externa
|
||||
} else {
|
||||
// si viene imagen_url externa, no debe haber imagen_path
|
||||
$data['imagen_path'] = null;
|
||||
}
|
||||
|
||||
// si publican sin fecha, poner ahora
|
||||
if (!empty($data['publicado']) && empty($data['fecha_publicacion'])) {
|
||||
$data['fecha_publicacion'] = now();
|
||||
}
|
||||
|
||||
$noticia = Noticia::create($data);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'data' => $noticia->fresh(),
|
||||
], 201);
|
||||
}
|
||||
|
||||
// PUT/PATCH /api/noticias/{noticia}
|
||||
public function update(Request $request, Noticia $noticia)
|
||||
{
|
||||
$data = $request->validate([
|
||||
'titulo' => ['sometimes', 'required', 'string', 'max:220'],
|
||||
'slug' => ['sometimes', 'nullable', 'string', 'max:260', 'unique:noticias,slug,' . $noticia->id],
|
||||
'descripcion_corta' => ['sometimes', 'nullable', 'string', 'max:500'],
|
||||
'contenido' => ['sometimes', 'nullable', 'string'],
|
||||
'categoria' => ['sometimes', 'nullable', 'string', 'max:80'],
|
||||
'tag_color' => ['sometimes', 'nullable', 'string', 'max:30'],
|
||||
|
||||
// ✅ dos formas de imagen
|
||||
'imagen' => ['sometimes', 'nullable', 'file', 'mimes:jpg,jpeg,png,webp', 'max:4096'],
|
||||
'imagen_url' => ['sometimes', 'nullable', 'url', 'max:600'],
|
||||
|
||||
'link_url' => ['sometimes', 'nullable', 'url', 'max:600'],
|
||||
'link_texto' => ['sometimes', 'nullable', 'string', 'max:120'],
|
||||
'fecha_publicacion' => ['sometimes', 'nullable', 'date'],
|
||||
'publicado' => ['sometimes', 'boolean'],
|
||||
'destacado' => ['sometimes', 'boolean'],
|
||||
'orden' => ['sometimes', 'integer'],
|
||||
]);
|
||||
|
||||
// Si llega imagen archivo, reemplaza la anterior y limpia imagen_url externa
|
||||
if ($request->hasFile('imagen')) {
|
||||
if ($noticia->imagen_path && Storage::disk('public')->exists($noticia->imagen_path)) {
|
||||
Storage::disk('public')->delete($noticia->imagen_path);
|
||||
}
|
||||
$path = $request->file('imagen')->store('noticias', 'public');
|
||||
|
||||
$data['imagen_path'] = $path;
|
||||
$data['imagen_url'] = null; // ✅ prioridad archivo
|
||||
}
|
||||
|
||||
// Si llega imagen_url (externa), borra la imagen física anterior y limpia imagen_path
|
||||
if (array_key_exists('imagen_url', $data) && !empty($data['imagen_url'])) {
|
||||
if ($noticia->imagen_path && Storage::disk('public')->exists($noticia->imagen_path)) {
|
||||
Storage::disk('public')->delete($noticia->imagen_path);
|
||||
}
|
||||
$data['imagen_path'] = null;
|
||||
}
|
||||
|
||||
// Si explícitamente mandan imagen_url = null (quitar url) y no mandan archivo,
|
||||
// no tocamos imagen_path (se queda como está). Si quieres que también limpie path,
|
||||
// dime y lo cambiamos.
|
||||
|
||||
// si se marca publicado y no hay fecha, set now
|
||||
if (
|
||||
array_key_exists('publicado', $data) &&
|
||||
$data['publicado'] &&
|
||||
empty($noticia->fecha_publicacion) &&
|
||||
empty($data['fecha_publicacion'])
|
||||
) {
|
||||
$data['fecha_publicacion'] = now();
|
||||
}
|
||||
|
||||
// si cambian titulo y slug no vino, regenerar slug (opcional)
|
||||
if (array_key_exists('titulo', $data) && !array_key_exists('slug', $data)) {
|
||||
$data['slug'] = Str::slug($data['titulo']);
|
||||
}
|
||||
|
||||
$noticia->update($data);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'data' => $noticia->fresh(),
|
||||
]);
|
||||
}
|
||||
|
||||
// DELETE /api/noticias/{noticia}
|
||||
public function destroy(Noticia $noticia)
|
||||
{
|
||||
if ($noticia->imagen_path && Storage::disk('public')->exists($noticia->imagen_path)) {
|
||||
Storage::disk('public')->delete($noticia->imagen_path);
|
||||
}
|
||||
|
||||
$noticia->delete();
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => 'Noticia eliminada correctamente',
|
||||
]);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,64 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Administracion;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Postulante;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Validator;
|
||||
|
||||
class PostulanteController extends Controller
|
||||
{
|
||||
public function obtenerPostulantes(Request $request)
|
||||
{
|
||||
$query = Postulante::query();
|
||||
|
||||
if ($request->buscar) {
|
||||
$query->where(function ($q) use ($request) {
|
||||
$q->where('name', 'like', "%{$request->buscar}%")
|
||||
->orWhere('email', 'like', "%{$request->buscar}%")
|
||||
->orWhere('dni', 'like', "%{$request->buscar}%");
|
||||
});
|
||||
}
|
||||
|
||||
$postulantes = $query->orderBy('id', 'desc')
|
||||
->paginate(20);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'data' => $postulantes
|
||||
]);
|
||||
}
|
||||
|
||||
public function actualizarPostulante(Request $request, $id)
|
||||
{
|
||||
$postulante = Postulante::findOrFail($id);
|
||||
|
||||
$request->validate([
|
||||
'name' => 'required|string|max:255',
|
||||
'email' => 'required|email|unique:postulantes,email,' . $postulante->id,
|
||||
'dni' => 'required|string|max:20|unique:postulantes,dni,' . $postulante->id,
|
||||
'password' => 'nullable|string|min:6'
|
||||
]);
|
||||
|
||||
$postulante->update([
|
||||
'name' => $request->name,
|
||||
'email' => $request->email,
|
||||
'dni' => $request->dni,
|
||||
]);
|
||||
|
||||
// 🔹 Solo si envían nueva contraseña
|
||||
if ($request->filled('password')) {
|
||||
$postulante->password = $request->password;
|
||||
$postulante->save();
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => 'Postulante actualizado correctamente',
|
||||
'data' => $postulante
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
@ -0,0 +1,70 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
use App\Models\ProcesoAdmision;
|
||||
use App\Models\ProcesoAdmisionDetalle;
|
||||
|
||||
class WebController extends Controller
|
||||
{
|
||||
|
||||
public function GetProcesoAdmision()
|
||||
{
|
||||
$procesos = ProcesoAdmision::select(
|
||||
'id',
|
||||
'titulo',
|
||||
'subtitulo',
|
||||
'descripcion',
|
||||
'slug',
|
||||
'tipo_proceso',
|
||||
'modalidad',
|
||||
'publicado',
|
||||
'fecha_publicacion',
|
||||
'fecha_inicio_preinscripcion',
|
||||
'fecha_fin_preinscripcion',
|
||||
'fecha_inicio_inscripcion',
|
||||
'fecha_fin_inscripcion',
|
||||
'fecha_examen1',
|
||||
'fecha_examen2',
|
||||
'fecha_resultados',
|
||||
'fecha_inicio_biometrico',
|
||||
'fecha_fin_biometrico',
|
||||
'imagen_path',
|
||||
'banner_path',
|
||||
'brochure_path',
|
||||
'link_preinscripcion',
|
||||
'link_inscripcion',
|
||||
'link_resultados',
|
||||
'link_reglamento',
|
||||
'estado',
|
||||
'created_at',
|
||||
'updated_at'
|
||||
)
|
||||
->with([
|
||||
'detalles' => function ($query) {
|
||||
$query->select(
|
||||
'id',
|
||||
'proceso_admision_id',
|
||||
'tipo',
|
||||
'titulo_detalle',
|
||||
'descripcion',
|
||||
'listas',
|
||||
'meta',
|
||||
'url',
|
||||
'imagen_path',
|
||||
'imagen_path_2',
|
||||
'created_at',
|
||||
'updated_at'
|
||||
);
|
||||
}
|
||||
])
|
||||
->latest() // 🔥 Esto ordena por created_at DESC
|
||||
->get();
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'data' => $procesos
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
@ -0,0 +1,66 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class Noticia extends Model
|
||||
{
|
||||
use SoftDeletes;
|
||||
|
||||
protected $table = 'noticias';
|
||||
|
||||
protected $fillable = [
|
||||
'titulo',
|
||||
'slug',
|
||||
'descripcion_corta',
|
||||
'contenido',
|
||||
'categoria',
|
||||
'tag_color',
|
||||
'imagen_path',
|
||||
'imagen_url', // ✅ agrega esto si también lo guardas en BD
|
||||
'link_url',
|
||||
'link_texto',
|
||||
'fecha_publicacion',
|
||||
'publicado',
|
||||
'destacado',
|
||||
'orden',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'fecha_publicacion' => 'datetime',
|
||||
'publicado' => 'boolean',
|
||||
'destacado' => 'boolean',
|
||||
'orden' => 'integer',
|
||||
];
|
||||
|
||||
// ✅ se incluirá en el JSON
|
||||
protected $appends = ['imagen_url'];
|
||||
|
||||
public function getImagenUrlAttribute(): ?string
|
||||
{
|
||||
// 1) Si en BD hay una URL externa, úsala
|
||||
if (!empty($this->attributes['imagen_url'])) {
|
||||
return $this->attributes['imagen_url'];
|
||||
}
|
||||
|
||||
// 2) Si hay imagen en storage, genera URL absoluta
|
||||
if (!empty($this->imagen_path)) {
|
||||
return url(Storage::disk('public')->url($this->imagen_path));
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
protected static function booted(): void
|
||||
{
|
||||
static::saving(function (Noticia $noticia) {
|
||||
if (!$noticia->slug) {
|
||||
$noticia->slug = Str::slug($noticia->titulo);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,37 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class ResultadoExamen extends Model
|
||||
{
|
||||
protected $table = 'resultados_examenes';
|
||||
|
||||
protected $fillable = [
|
||||
'postulante_id',
|
||||
'examen_id',
|
||||
'total_puntos',
|
||||
'correctas_por_curso',
|
||||
'incorrectas_por_curso',
|
||||
'preguntas_totales_por_curso',
|
||||
'total_correctas',
|
||||
'total_incorrectas',
|
||||
'total_nulas',
|
||||
'porcentaje_correctas',
|
||||
'calificacion_sobre_20',
|
||||
'orden_merito',
|
||||
'probabilidad_ingreso',
|
||||
'programa_recomendado',
|
||||
];
|
||||
|
||||
public function postulante()
|
||||
{
|
||||
return $this->belongsTo(Postulante::class);
|
||||
}
|
||||
|
||||
public function examen()
|
||||
{
|
||||
return $this->belongsTo(Examen::class);
|
||||
}
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 304 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 355 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 4.8 MiB |
Binary file not shown.
|
After Width: | Height: | Size: 346 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 245 KiB |
@ -1,230 +1,167 @@
|
||||
<!-- components/contact/ContactSection.vue -->
|
||||
<template>
|
||||
<section class="contact-section">
|
||||
<section class="process-section" aria-labelledby="faq-title">
|
||||
<div class="section-container">
|
||||
<!-- Preguntas Frecuentes -->
|
||||
<div class="faq-block">
|
||||
<div class="section-header">
|
||||
<h2 class="section-title">Preguntas Frecuentes</h2>
|
||||
<p class="section-subtitle">Resuelve tus dudas sobre el proceso de admision</p>
|
||||
</div>
|
||||
|
||||
<a-collapse
|
||||
v-model:activeKey="activeFaqKeys"
|
||||
:bordered="false"
|
||||
expand-icon-position="end"
|
||||
class="faq-collapse"
|
||||
>
|
||||
<a-collapse-panel
|
||||
v-for="(faq, index) in faqs"
|
||||
:key="String(index)"
|
||||
class="faq-panel"
|
||||
>
|
||||
<template #header>
|
||||
<div class="faq-header">
|
||||
<span class="faq-number">{{ String(index + 1).padStart(2, '0') }}</span>
|
||||
<span class="faq-question">{{ faq.pregunta }}</span>
|
||||
</div>
|
||||
</template>
|
||||
<div class="faq-answer" v-html="faq.respuesta"></div>
|
||||
|
||||
<div class="section-header">
|
||||
<h2 id="faq-title" class="section-title">
|
||||
Preguntas Frecuentes
|
||||
</h2>
|
||||
<p class="section-subtitle">
|
||||
Resolvemos las dudas más comunes sobre el proceso de admisión y el uso de nuestra plataforma.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="process-card">
|
||||
|
||||
|
||||
<a-collapse accordion class="modern-collapse">
|
||||
|
||||
<a-collapse-panel key="1" header="01. ¿Puedo postular con DNI caducado?">
|
||||
No, su DNI debe estar vigente al momento de la inscripción.
|
||||
En su defecto, puede presentar la Hoja C4 o el ticket de trámite acompañado de una copia del DNI.
|
||||
</a-collapse-panel>
|
||||
|
||||
<a-collapse-panel key="2" header="02. ¿Qué requisitos debo presentar si postulo como persona con discapacidad?">
|
||||
Certificado de Discapacidad emitido por entidades de salud acreditadas en el RENIPRESS.
|
||||
Este debe ser adjuntado junto a los demás requisitos.
|
||||
</a-collapse-panel>
|
||||
|
||||
<a-collapse-panel key="3" header="03. ¿Qué hago si ingreso mal mis datos en la preinscripción?">
|
||||
No es posible modificarlos en línea una vez guardados.
|
||||
Debe acercarse al local de inscripción y solicitar ayuda en la oficina de soporte técnico.
|
||||
</a-collapse-panel>
|
||||
|
||||
<a-collapse-panel key="4" header="04. ¿Cuáles son los códigos para realizar los pagos?">
|
||||
<p><b>Código 26:</b> Derechos de Admisión.</p>
|
||||
<p><b>Código 27:</b> Rezagados al proceso de inscripción.</p>
|
||||
<p><b>Código 28:</b> Carpeta de postulante.</p>
|
||||
</a-collapse-panel>
|
||||
|
||||
<a-collapse-panel key="5" header="05. ¿Cómo debo realizar la preinscripción e inscripción?">
|
||||
Ingrese a admision.unap.edu.pe para la preinscripción.
|
||||
<br /><br />
|
||||
La inscripción presencial se realiza según el último dígito del DNI, según el cronograma oficial.
|
||||
</a-collapse-panel>
|
||||
|
||||
<a-collapse-panel key="6" header="06. ¿Qué requisitos debo presentar si postulo a Medicina Humana o Educación Física?">
|
||||
Constancia de evaluación médica emitida por un establecimiento de salud primario,
|
||||
además de los demás requisitos exigidos.
|
||||
</a-collapse-panel>
|
||||
|
||||
<a-collapse-panel key="7" header="07. ¿Puedo cambiar de programa de estudio luego de inscribirme?">
|
||||
Sí. Tras obtener su constancia de inscripción, debe realizar un pago de S/.100 (Código 27)
|
||||
en caja de la Universidad o Banco de la Nación, incluyendo comisión.
|
||||
</a-collapse-panel>
|
||||
|
||||
<a-collapse-panel key="8" header="08. ¿Qué debo traer para inscribirme presencialmente?">
|
||||
<ul>
|
||||
<li>Comprobantes de pago o vouchers</li>
|
||||
<li>DNI original y copia</li>
|
||||
<li>Solicitud de Preinscripción impresa</li>
|
||||
<li>Certificado de estudios original y copia</li>
|
||||
</ul>
|
||||
</a-collapse-panel>
|
||||
|
||||
<a-collapse-panel key="9" header="09. ¿Los postulantes con discapacidad hacen cola?">
|
||||
No. Deben presentar su Certificado de Discapacidad y se les brindará atención preferencial.
|
||||
</a-collapse-panel>
|
||||
|
||||
</a-collapse>
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
|
||||
const activeFaqKeys = ref([])
|
||||
|
||||
const faqs = [
|
||||
{
|
||||
pregunta: '¿Puedo postular con DNI caducado?',
|
||||
respuesta: 'No, su DNI debe estar vigente al momento de la inscripcion. En su defecto, puede presentar la Hoja C4 o el ticket de tramite acompañado de una copia del DNI.',
|
||||
},
|
||||
{
|
||||
pregunta: '¿Que requisitos debo presentar si postulo como persona con discapacidad?',
|
||||
respuesta: 'Certificado de Discapacidad emitido por entidades de salud acreditadas en el RENIPRESS. Este debe ser adjuntado junto a los demas requisitos.',
|
||||
},
|
||||
{
|
||||
pregunta: '¿Que hago si ingreso mal mis datos en la preinscripcion?',
|
||||
respuesta: 'No es posible modificarlos en linea una vez guardados. Debe acercarse al local de inscripcion y solicitar ayuda en la oficina de soporte tecnico.',
|
||||
},
|
||||
{
|
||||
pregunta: '¿Cuales son los codigos para realizar los pagos?',
|
||||
respuesta: '<p><strong>Codigo 26:</strong> Derechos de Admision.</p><p><strong>Codigo 27:</strong> Rezagados al proceso de inscripcion.</p><p><strong>Codigo 28:</strong> Carpeta de postulante.</p>',
|
||||
},
|
||||
{
|
||||
pregunta: '¿Como debo realizar la preinscripcion e inscripcion?',
|
||||
respuesta: 'Ingrese a <a href="https://admision.unap.edu.pe" target="_blank" rel="noopener noreferrer" class="faq-link">admision.unap.edu.pe</a> para la preinscripcion. La inscripcion presencial se realiza segun el ultimo digito del DNI, segun el cronograma oficial.',
|
||||
},
|
||||
{
|
||||
pregunta: '¿Que requisitos debo presentar si postulo a Medicina Humana o Educacion Fisica?',
|
||||
respuesta: 'Constancia de evaluacion medica emitida por un establecimiento de salud primario, ademas de los demas requisitos exigidos.',
|
||||
},
|
||||
{
|
||||
pregunta: '¿Puedo cambiar de programa de estudio luego de inscribirme?',
|
||||
respuesta: 'Si. Tras obtener su constancia de inscripcion, debe realizar un pago de S/.100 (Codigo 27) en caja de la Universidad o Banco de la Nacion, incluyendo comision.',
|
||||
},
|
||||
{
|
||||
pregunta: '¿Que debo traer para inscribirme presencialmente?',
|
||||
respuesta: '<ul><li>Comprobantes de pago o vouchers</li><li>DNI original y copia</li><li>Solicitud de Preinscripcion impresa (descargada de la web)</li><li>Certificado de estudios original y copia</li></ul>',
|
||||
},
|
||||
{
|
||||
pregunta: '¿Los postulantes con discapacidad hacen cola?',
|
||||
respuesta: 'No. Deben presentar su Certificado de Discapacidad y se les brindara atencion preferencial.',
|
||||
},
|
||||
]
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.contact-section {
|
||||
padding: 60px 0 40px;
|
||||
font-family: "Lora", serif;
|
||||
|
||||
.process-section {
|
||||
padding: 30px 0;
|
||||
background: #ffffff;
|
||||
font-family: "Times New Roman", Times, serif;
|
||||
}
|
||||
|
||||
.section-container {
|
||||
max-width: 1200px;
|
||||
max-width: 1100px;
|
||||
margin: 0 auto;
|
||||
padding: 0 24px;
|
||||
}
|
||||
|
||||
/* ── FAQ ── */
|
||||
.faq-block {
|
||||
margin-bottom: 0;
|
||||
padding: 0 20px;
|
||||
}
|
||||
|
||||
.section-header {
|
||||
text-align: center;
|
||||
margin-bottom: 48px;
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 2.5rem;
|
||||
font-size: 2.1rem;
|
||||
font-weight: 700;
|
||||
color: #0d1b52;
|
||||
margin: 0 0 12px;
|
||||
color: #2c3e50;
|
||||
margin: 0 0 6px 0;
|
||||
}
|
||||
|
||||
.section-subtitle {
|
||||
font-size: 1.125rem;
|
||||
color: #666;
|
||||
max-width: 540px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.faq-collapse {
|
||||
background: transparent;
|
||||
font-size: 1rem;
|
||||
color: #777;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.faq-panel {
|
||||
.process-card {
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 14px;
|
||||
padding: 20px 18px;
|
||||
box-shadow: 0 6px 18px rgba(0, 0, 0, 0.04);
|
||||
background: #fff;
|
||||
border: 1px solid #e8ecf4 !important;
|
||||
border-radius: 14px !important;
|
||||
margin-bottom: 14px;
|
||||
overflow: hidden;
|
||||
transition: box-shadow 0.25s ease;
|
||||
}
|
||||
|
||||
.faq-panel:hover {
|
||||
box-shadow: 0 6px 20px rgba(0, 0, 0, 0.07);
|
||||
}
|
||||
|
||||
.faq-panel :deep(.ant-collapse-header) {
|
||||
padding: 20px 24px !important;
|
||||
align-items: center !important;
|
||||
}
|
||||
|
||||
.faq-panel :deep(.ant-collapse-content-box) {
|
||||
padding: 0 24px 22px 24px !important;
|
||||
}
|
||||
|
||||
.faq-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.faq-number {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 10px;
|
||||
background: linear-gradient(135deg, #1a237e 0%, #283593 100%);
|
||||
color: #fff;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 700;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.faq-question {
|
||||
font-weight: 600;
|
||||
color: #1a237e;
|
||||
font-size: 1rem;
|
||||
line-height: 1.4;
|
||||
.illustration-box {
|
||||
text-align: center;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.faq-answer {
|
||||
color: #555;
|
||||
font-size: 0.975rem;
|
||||
line-height: 1.75;
|
||||
padding-left: 50px;
|
||||
.illustration-img {
|
||||
width: 160px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.faq-answer :deep(ul) {
|
||||
padding-left: 20px;
|
||||
margin: 0;
|
||||
.illustration-text {
|
||||
font-size: 0.9rem;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.faq-answer :deep(ul li) {
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.faq-answer :deep(p) {
|
||||
margin: 0 0 4px;
|
||||
.modern-collapse {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.faq-answer :deep(.faq-link) {
|
||||
color: #1890ff;
|
||||
text-decoration: underline;
|
||||
.modern-collapse :deep(.ant-collapse-item) {
|
||||
border-radius: 10px !important;
|
||||
margin-bottom: 10px;
|
||||
background: #f8fafc;
|
||||
border: 1px solid #e5e7eb;
|
||||
}
|
||||
|
||||
.faq-answer :deep(.faq-link:hover) {
|
||||
color: #0d1b52;
|
||||
.modern-collapse :deep(.ant-collapse-header) {
|
||||
font-weight: 700;
|
||||
color: #1e3a8a;
|
||||
}
|
||||
|
||||
@media (max-width: 992px) {
|
||||
.section-title {
|
||||
font-size: 2rem;
|
||||
}
|
||||
|
||||
.faq-answer {
|
||||
padding-left: 0;
|
||||
}
|
||||
.modern-collapse :deep(.ant-collapse-content) {
|
||||
background: #ffffff;
|
||||
border-top: 1px solid #eef2f7;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.contact-section {
|
||||
padding: 40px 0 30px;
|
||||
}
|
||||
|
||||
.faq-panel :deep(.ant-collapse-header) {
|
||||
padding: 16px 18px !important;
|
||||
.section-title {
|
||||
font-size: 1.6rem;
|
||||
}
|
||||
|
||||
.faq-number {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
font-size: 0.75rem;
|
||||
.process-card {
|
||||
padding: 14px 12px;
|
||||
}
|
||||
|
||||
.faq-question {
|
||||
font-size: 0.925rem;
|
||||
.illustration-img {
|
||||
width: 120px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@ -0,0 +1,124 @@
|
||||
<template>
|
||||
<NavbarModerno />
|
||||
<section class="convocatorias-modern">
|
||||
<div class="section-container">
|
||||
<div class="section-header">
|
||||
<div class="header-with-badge">
|
||||
<h2 class="section-title">CEPREUNA</h2>
|
||||
<a-badge count="Modalidad" class="new-badge" />
|
||||
</div>
|
||||
<p class="section-subtitle">
|
||||
Examen de admisión del Centro Preuniversitario
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<a-card class="main-convocatoria-card">
|
||||
<div class="card-badge">CEPREUNA</div>
|
||||
|
||||
<div class="convocatoria-header">
|
||||
<div>
|
||||
<h3>Examen de Admisión del Centro Preuniversitario</h3>
|
||||
<p class="convocatoria-date">Convocatoria: una vez por semestre</p>
|
||||
</div>
|
||||
<!-- <a-tag class="status-tag" color="success">CEPREUNA</a-tag> -->
|
||||
</div>
|
||||
|
||||
<a-alert
|
||||
type="info"
|
||||
show-icon
|
||||
class="soft-alert"
|
||||
message="Dirigido a estudiantes que concluyeron el quinto año de secundaria y cursaron el ciclo preparatorio del Centro Preuniversitario de la UNA-Puno."
|
||||
/>
|
||||
|
||||
<a-divider class="custom-divider" />
|
||||
|
||||
<a-card class="soft-card" size="small">
|
||||
<h4 class="subheading">Evaluación</h4>
|
||||
<p class="text">
|
||||
El postulante rinde un examen de conocimientos con contenidos alineados al perfil del ingresante
|
||||
establecido por la UNA-Puno, en la fecha indicada en el cronograma de admisión.
|
||||
</p>
|
||||
</a-card>
|
||||
|
||||
|
||||
<a-card class="soft-card" size="small">
|
||||
<h4 class="subheading">Requisitos y documentos</h4>
|
||||
<a-list size="small" :split="false" class="info-list">
|
||||
<a-list-item>
|
||||
Presentar la constancia de no adeudar al CEPREUNA y DNI vigente.
|
||||
</a-list-item>
|
||||
<a-list-item>
|
||||
Presentar los documentos exigidos por el reglamento para esta modalidad (según requisitos generales).
|
||||
</a-list-item>
|
||||
</a-list>
|
||||
</a-card>
|
||||
</a-card>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<FooterModerno />
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import NavbarModerno from '../../nabvar.vue'
|
||||
import FooterModerno from '../../Footer.vue'
|
||||
|
||||
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* Diseño tipo Convocatorias (sin mayúsculas globales) */
|
||||
.convocatorias-modern {
|
||||
position: relative;
|
||||
padding: 40px 0;
|
||||
font-family: "Times New Roman", Times, serif;
|
||||
background: #fbfcff;
|
||||
overflow: hidden;
|
||||
}
|
||||
.convocatorias-modern::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
z-index: 0;
|
||||
background-image:
|
||||
repeating-linear-gradient(to right, rgba(13, 27, 82, 0.06) 0, rgba(13, 27, 82, 0.06) 1px, transparent 1px, transparent 24px),
|
||||
repeating-linear-gradient(to bottom, rgba(13, 27, 82, 0.06) 0, rgba(13, 27, 82, 0.06) 1px, transparent 1px, transparent 24px);
|
||||
opacity: 0.55;
|
||||
}
|
||||
.section-container { position: relative; z-index: 1; max-width: 1200px; margin: 0 auto; padding: 0 24px; }
|
||||
.section-header { text-align: center; margin-bottom: 40px; }
|
||||
.header-with-badge { display: inline-flex; align-items: center; justify-content: center; gap: 14px; }
|
||||
.section-title { font-size: 2.4rem; font-weight: 700; color: #0d1b52; margin: 0; }
|
||||
.section-subtitle { font-size: 1.125rem; color: #666; max-width: 760px; margin: 14px auto 0; }
|
||||
|
||||
.new-badge :deep(.ant-badge-count) {
|
||||
background: linear-gradient(45deg, #ff6b6b, #ffd700);
|
||||
box-shadow: 0 2px 8px rgba(255, 107, 107, 0.3);
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
.main-convocatoria-card { position: relative; border: none; box-shadow: 0 10px 34px rgba(0,0,0,0.08); border-radius: 16px; }
|
||||
.main-convocatoria-card :deep(.ant-card-body) { padding: 28px; }
|
||||
|
||||
.card-badge {
|
||||
position: absolute; top: -12px; left: 24px;
|
||||
background: linear-gradient(45deg, #1890ff, #52c41a);
|
||||
color: white; padding: 6px 16px; border-radius: 999px;
|
||||
font-size: 0.75rem; font-weight: 700;
|
||||
}
|
||||
|
||||
.convocatoria-header { display: flex; justify-content: space-between; align-items: flex-start; gap: 14px; margin-bottom: 14px; }
|
||||
.convocatoria-header h3 { margin: 0; font-size: 1.55rem; color: #1a237e; }
|
||||
.convocatoria-date { color: #666; margin: 6px 0 0; font-size: 0.95rem; }
|
||||
|
||||
.status-tag { font-weight: 700; padding: 4px 12px; border-radius: 999px; white-space: nowrap; }
|
||||
|
||||
.custom-divider { margin: 18px 0; }
|
||||
.soft-alert { border-radius: 12px; }
|
||||
|
||||
.soft-card { border-radius: 12px; border: 1px solid rgba(0,0,0,0.06); margin-top: 12px; }
|
||||
.subheading { margin: 0 0 6px; color: #1a237e; font-weight: 700; }
|
||||
.text { margin: 0; color: #666; line-height: 1.7; }
|
||||
.info-list :deep(.ant-list-item) { padding: 8px 0; border: none; color: #666; }
|
||||
</style>
|
||||
@ -0,0 +1,141 @@
|
||||
<template>
|
||||
<NavbarModerno/>
|
||||
<section class="convocatorias-modern">
|
||||
<div class="section-container">
|
||||
<div class="section-header">
|
||||
<div class="header-with-badge">
|
||||
<h2 class="section-title">Extraordinario</h2>
|
||||
<a-badge count="Modalidades" class="new-badge" />
|
||||
</div>
|
||||
<p class="section-subtitle">
|
||||
Examen convocado una vez al año con varias formas de postulación
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<a-card class="main-convocatoria-card">
|
||||
<div class="card-badge">Extraordinario</div>
|
||||
|
||||
<div class="convocatoria-header">
|
||||
<div>
|
||||
<h3>Examen de Admisión Extraordinario</h3>
|
||||
<p class="convocatoria-date">Convocatoria: una vez al año</p>
|
||||
</div>
|
||||
<!-- <a-tag class="status-tag" color="orange">Extraordinario</a-tag> -->
|
||||
</div>
|
||||
|
||||
<a-card class="soft-card" size="small">
|
||||
<h4 class="subheading">Evaluación</h4>
|
||||
<p class="text">
|
||||
El postulante rinde un examen de conocimientos con temas y contenidos alineados al perfil del ingresante,
|
||||
en la fecha prevista en el cronograma de admisión.
|
||||
</p>
|
||||
</a-card>
|
||||
|
||||
|
||||
<a-divider class="custom-divider" />
|
||||
|
||||
<h4 class="subheading" style="margin: 0 0 10px;">Formas de postulación</h4>
|
||||
|
||||
<a-collapse expand-icon-position="end" class="collapse-clean">
|
||||
<a-collapse-panel key="a" header="Primeros puestos / COAR">
|
||||
<p class="text">
|
||||
Dirigido a estudiantes que obtuvieron los primeros lugares del orden de mérito en secundaria, con vigencia
|
||||
definida por el reglamento; incluye egresados del COAR según corresponda.
|
||||
</p>
|
||||
<a-divider class="custom-divider" />
|
||||
<a-list size="small" :split="false" class="info-list">
|
||||
<a-list-item>Comprobantes de pago por admisión y carpeta de postulante.</a-list-item>
|
||||
<a-list-item>Documento de identidad (original y copia).</a-list-item>
|
||||
<a-list-item>Examen médico y aptitud vocacional solo para programas que lo exigen.</a-list-item>
|
||||
<a-list-item>Solicitud generada tras la preinscripción virtual.</a-list-item>
|
||||
<a-list-item>Certificados que acrediten estudios y condición de mérito según corresponda.</a-list-item>
|
||||
</a-list>
|
||||
</a-collapse-panel>
|
||||
|
||||
<a-collapse-panel key="b" header="Graduados o titulados">
|
||||
<a-list size="small" :split="false" class="info-list">
|
||||
<a-list-item>Comprobantes de pago por admisión y carpeta.</a-list-item>
|
||||
<a-list-item>Documento de identidad (original y copia).</a-list-item>
|
||||
<a-list-item>Solicitud generada tras la preinscripción virtual.</a-list-item>
|
||||
<a-list-item>Grado o título certificado por la institución de origen.</a-list-item>
|
||||
<a-list-item>Para extranjeros: legalizaciones requeridas según normativa.</a-list-item>
|
||||
<a-list-item>Constancias de institución de origen o verificación según corresponda.</a-list-item>
|
||||
</a-list>
|
||||
</a-collapse-panel>
|
||||
|
||||
<a-collapse-panel key="c" header="Traslado interno">
|
||||
<a-list size="small" :split="false" class="info-list">
|
||||
<a-list-item>Comprobantes de pago por admisión y carpeta.</a-list-item>
|
||||
<a-list-item>Documento de identidad (original y copia).</a-list-item>
|
||||
<a-list-item>Solicitud indicando programa de origen y programa al que postula, según afinidad.</a-list-item>
|
||||
<a-list-item>Historial académico y constancias de matrícula según requisitos establecidos.</a-list-item>
|
||||
</a-list>
|
||||
</a-collapse-panel>
|
||||
|
||||
<a-collapse-panel key="d" header="Traslado externo (nacional o internacional)">
|
||||
<a-list size="small" :split="false" class="info-list">
|
||||
<a-list-item>Comprobantes de pago por admisión y carpeta.</a-list-item>
|
||||
<a-list-item>Documento de identidad (DNI / carné de extranjería / pasaporte) según corresponda.</a-list-item>
|
||||
<a-list-item>Solicitud de postulación al mismo programa de estudio.</a-list-item>
|
||||
<a-list-item>Certificados de estudios visados; en internacional, requisitos legales adicionales.</a-list-item>
|
||||
<a-list-item>Constancia de matrícula vigente del semestre anterior o similar.</a-list-item>
|
||||
</a-list>
|
||||
</a-collapse-panel>
|
||||
|
||||
<a-collapse-panel key="e" header="Deportistas destacados">
|
||||
<a-list size="small" :split="false" class="info-list">
|
||||
<a-list-item>Comprobantes de pago por admisión y carpeta.</a-list-item>
|
||||
<a-list-item>Documento de identidad (original y copia).</a-list-item>
|
||||
<a-list-item>Resolución y récord deportivo documentado conforme a la normativa aplicable.</a-list-item>
|
||||
<a-list-item>Certificado de estudios secundarios.</a-list-item>
|
||||
</a-list>
|
||||
</a-collapse-panel>
|
||||
|
||||
<a-collapse-panel key="f" header="Beneficiarios del Plan Integral de Reparaciones (PIR)">
|
||||
<a-list size="small" :split="false" class="info-list">
|
||||
<a-list-item>Solicitud generada tras la preinscripción virtual.</a-list-item>
|
||||
<a-list-item>Documento de identidad (original y copia).</a-list-item>
|
||||
<a-list-item>Constancias de registro correspondientes (RUV/REBRED u otras).</a-list-item>
|
||||
<a-list-item>Examen médico y aptitud vocacional solo para programas que lo exigen.</a-list-item>
|
||||
<a-list-item>Certificado de estudios secundarios.</a-list-item>
|
||||
</a-list>
|
||||
</a-collapse-panel>
|
||||
</a-collapse>
|
||||
</a-card>
|
||||
</div>
|
||||
</section>
|
||||
<FooterModerno/>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import NavbarModerno from '../../nabvar.vue'
|
||||
import FooterModerno from '../../Footer.vue'
|
||||
|
||||
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* mismo estilo base del componente CEPREUNA */
|
||||
.convocatorias-modern{position:relative;padding:40px 0;font-family:"Times New Roman",Times,serif;background:#fbfcff;overflow:hidden;}
|
||||
.convocatorias-modern::before{content:"";position:absolute;inset:0;pointer-events:none;z-index:0;background-image:repeating-linear-gradient(to right,rgba(13,27,82,.06) 0,rgba(13,27,82,.06) 1px,transparent 1px,transparent 24px),repeating-linear-gradient(to bottom,rgba(13,27,82,.06) 0,rgba(13,27,82,.06) 1px,transparent 1px,transparent 24px);opacity:.55;}
|
||||
.section-container{position:relative;z-index:1;max-width:1200px;margin:0 auto;padding:0 24px;}
|
||||
.section-header{text-align:center;margin-bottom:40px;}
|
||||
.header-with-badge{display:inline-flex;align-items:center;justify-content:center;gap:14px;}
|
||||
.section-title{font-size:2.4rem;font-weight:700;color:#0d1b52;margin:0;}
|
||||
.section-subtitle{font-size:1.125rem;color:#666;max-width:860px;margin:14px auto 0;}
|
||||
.new-badge :deep(.ant-badge-count){background:linear-gradient(45deg,#ff6b6b,#ffd700);box-shadow:0 2px 8px rgba(255,107,107,.3);border-radius:999px;}
|
||||
.main-convocatoria-card{position:relative;border:none;box-shadow:0 10px 34px rgba(0,0,0,.08);border-radius:16px;}
|
||||
.main-convocatoria-card :deep(.ant-card-body){padding:28px;}
|
||||
.card-badge{position:absolute;top:-12px;left:24px;background:linear-gradient(45deg,#1890ff,#52c41a);color:#fff;padding:6px 16px;border-radius:999px;font-size:.75rem;font-weight:700;}
|
||||
.convocatoria-header{display:flex;justify-content:space-between;align-items:flex-start;gap:14px;margin-bottom:14px;}
|
||||
.convocatoria-header h3{margin:0;font-size:1.55rem;color:#1a237e;}
|
||||
.convocatoria-date{color:#666;margin:6px 0 0;font-size:.95rem;}
|
||||
.status-tag{font-weight:700;padding:4px 12px;border-radius:999px;white-space:nowrap;}
|
||||
.custom-divider{margin:18px 0;}
|
||||
.soft-card{border-radius:12px;border:1px solid rgba(0,0,0,0.06);margin-top:12px;}
|
||||
.subheading{margin:0 0 6px;color:#1a237e;font-weight:700;}
|
||||
.text{margin:0;color:#666;line-height:1.7;}
|
||||
.info-list :deep(.ant-list-item){padding:8px 0;border:none;color:#666;}
|
||||
.collapse-clean :deep(.ant-collapse-item){border-radius:12px;overflow:hidden;margin-bottom:10px;border:1px solid rgba(0,0,0,0.06);}
|
||||
.collapse-clean :deep(.ant-collapse-header){font-weight:700;color:#1a237e;}
|
||||
</style>
|
||||
@ -0,0 +1,99 @@
|
||||
<template>
|
||||
<NavbarModerno />
|
||||
<section class="convocatorias-modern">
|
||||
<div class="section-container">
|
||||
<div class="section-header">
|
||||
<div class="header-with-badge">
|
||||
<h2 class="section-title">Admisión General</h2>
|
||||
<a-badge count="Semestral" class="new-badge" />
|
||||
</div>
|
||||
<p class="section-subtitle">
|
||||
Modalidad dirigida a egresados de secundaria (incluye postulantes con discapacidad acreditada)
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<a-card class="main-convocatoria-card">
|
||||
<div class="card-badge">General</div>
|
||||
|
||||
<div class="convocatoria-header">
|
||||
<div>
|
||||
<h3>Examen de Admisión General</h3>
|
||||
<p class="convocatoria-date">Convocatoria: una vez por semestre</p>
|
||||
</div>
|
||||
<!--
|
||||
<a-tag class="status-tag" color="blue">General</a-tag> -->
|
||||
</div>
|
||||
|
||||
<a-alert
|
||||
type="info"
|
||||
show-icon
|
||||
class="soft-alert"
|
||||
message="Incluye postulantes con discapacidad debidamente acreditados mediante su certificado correspondiente."
|
||||
/>
|
||||
|
||||
<a-divider class="custom-divider" />
|
||||
|
||||
<a-card class="soft-card" size="small">
|
||||
<h4 class="subheading">A quién está dirigido</h4>
|
||||
<p class="text">
|
||||
Dirigido a estudiantes egresados que hayan concluido educación secundaria en EBR, EBA y COAR.
|
||||
</p>
|
||||
</a-card>
|
||||
|
||||
<a-card class="soft-card" size="small">
|
||||
<h4 class="subheading">Evaluación</h4>
|
||||
<p class="text">
|
||||
Examen de conocimientos basado en contenidos alineados al perfil del ingresante, según el cronograma de admisión.
|
||||
</p>
|
||||
</a-card>
|
||||
|
||||
<a-card class="soft-card" size="small">
|
||||
<h4 class="subheading">Asignación de vacantes</h4>
|
||||
<p class="text">
|
||||
Se asignan por puntaje hasta completar el número ofertado por los programas de estudio, conforme al reglamento.
|
||||
</p>
|
||||
</a-card>
|
||||
|
||||
<a-card class="soft-card" size="small">
|
||||
<h4 class="subheading">Documentación</h4>
|
||||
<p class="text">
|
||||
El postulante debe presentar los documentos exigidos por el reglamento para esta modalidad.
|
||||
</p>
|
||||
</a-card>
|
||||
</a-card>
|
||||
</div>
|
||||
</section>
|
||||
<FooterModerno/>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import NavbarModerno from '../../nabvar.vue'
|
||||
import FooterModerno from '../../Footer.vue'
|
||||
|
||||
|
||||
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* mismo estilo base */
|
||||
.convocatorias-modern{position:relative;padding:40px 0;font-family:"Times New Roman",Times,serif;background:#fbfcff;overflow:hidden;}
|
||||
.convocatorias-modern::before{content:"";position:absolute;inset:0;pointer-events:none;z-index:0;background-image:repeating-linear-gradient(to right,rgba(13,27,82,.06) 0,rgba(13,27,82,.06) 1px,transparent 1px,transparent 24px),repeating-linear-gradient(to bottom,rgba(13,27,82,.06) 0,rgba(13,27,82,.06) 1px,transparent 1px,transparent 24px);opacity:.55;}
|
||||
.section-container{position:relative;z-index:1;max-width:1200px;margin:0 auto;padding:0 24px;}
|
||||
.section-header{text-align:center;margin-bottom:40px;}
|
||||
.header-with-badge{display:inline-flex;align-items:center;justify-content:center;gap:14px;}
|
||||
.section-title{font-size:2.4rem;font-weight:700;color:#0d1b52;margin:0;}
|
||||
.section-subtitle{font-size:1.125rem;color:#666;max-width:860px;margin:14px auto 0;}
|
||||
.new-badge :deep(.ant-badge-count){background:linear-gradient(45deg,#ff6b6b,#ffd700);box-shadow:0 2px 8px rgba(255,107,107,.3);border-radius:999px;}
|
||||
.main-convocatoria-card{position:relative;border:none;box-shadow:0 10px 34px rgba(0,0,0,.08);border-radius:16px;}
|
||||
.main-convocatoria-card :deep(.ant-card-body){padding:28px;}
|
||||
.card-badge{position:absolute;top:-12px;left:24px;background:linear-gradient(45deg,#1890ff,#52c41a);color:#fff;padding:6px 16px;border-radius:999px;font-size:.75rem;font-weight:700;}
|
||||
.convocatoria-header{display:flex;justify-content:space-between;align-items:flex-start;gap:14px;margin-bottom:14px;}
|
||||
.convocatoria-header h3{margin:0;font-size:1.55rem;color:#1a237e;}
|
||||
.convocatoria-date{color:#666;margin:6px 0 0;font-size:.95rem;}
|
||||
.status-tag{font-weight:700;padding:4px 12px;border-radius:999px;white-space:nowrap;}
|
||||
.custom-divider{margin:18px 0;}
|
||||
.soft-alert{border-radius:12px;}
|
||||
.soft-card{border-radius:12px;border:1px solid rgba(0,0,0,0.06);margin-top:12px;}
|
||||
.subheading{margin:0 0 6px;color:#1a237e;font-weight:700;}
|
||||
.text{margin:0;color:#666;line-height:1.7;}
|
||||
</style>
|
||||
@ -0,0 +1,411 @@
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from "vue"
|
||||
import axios from "axios"
|
||||
import NavbarModerno from '../../nabvar.vue'
|
||||
import FooterModerno from '../../Footer.vue'
|
||||
import {
|
||||
CalendarOutlined,
|
||||
FileSearchOutlined,
|
||||
} from "@ant-design/icons-vue"
|
||||
|
||||
|
||||
|
||||
const procesos = ref([])
|
||||
const loading = ref(false)
|
||||
const errorMsg = ref("")
|
||||
|
||||
const yaPasoFechaExamen = (fec_2, fec_1) => {
|
||||
if (!fec_2 && !fec_1) return true
|
||||
|
||||
const fechaReferencia = fec_2 || fec_1
|
||||
const fecha = new Date(fechaReferencia)
|
||||
|
||||
if (Number.isNaN(fecha.getTime())) return true
|
||||
|
||||
fecha.setDate(fecha.getDate() + 1)
|
||||
|
||||
const hoy = new Date()
|
||||
return hoy >= fecha
|
||||
}
|
||||
|
||||
const formatFecha = (value) => {
|
||||
if (!value) return ""
|
||||
const d = new Date(value)
|
||||
if (Number.isNaN(d.getTime())) return ""
|
||||
return d.toLocaleDateString("es-PE", { year: "numeric", month: "short", day: "2-digit" })
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
loading.value = true
|
||||
errorMsg.value = ""
|
||||
try {
|
||||
const response = await axios.get("https://inscripciones.admision.unap.edu.pe/api/get-procesos")
|
||||
if (response.data?.estado) {
|
||||
procesos.value = Array.isArray(response.data?.res) ? response.data.res : []
|
||||
} else {
|
||||
procesos.value = []
|
||||
errorMsg.value = "La API respondió en un formato inesperado."
|
||||
}
|
||||
} catch (error) {
|
||||
procesos.value = []
|
||||
errorMsg.value = "Error al cargar procesos."
|
||||
console.error("Error al cargar procesos:", error)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
})
|
||||
|
||||
const procesosAgrupados = computed(() => {
|
||||
const agrupado = {}
|
||||
for (const proceso of procesos.value) {
|
||||
const anio = proceso?.anio ?? "Sin año"
|
||||
if (!agrupado[anio]) agrupado[anio] = []
|
||||
agrupado[anio].push(proceso)
|
||||
}
|
||||
return agrupado
|
||||
})
|
||||
|
||||
const aniosOrdenados = computed(() => {
|
||||
return Object.keys(procesosAgrupados.value).sort((a, b) => Number(b) - Number(a))
|
||||
})
|
||||
|
||||
/** Solo resultados (examen ya pasó) */
|
||||
const resultadosPorAnio = computed(() => {
|
||||
const out = {}
|
||||
for (const anio of aniosOrdenados.value) {
|
||||
out[anio] = (procesosAgrupados.value[anio] || []).filter((p) =>
|
||||
yaPasoFechaExamen(p?.fec_2, p?.fec_1)
|
||||
)
|
||||
}
|
||||
return out
|
||||
})
|
||||
|
||||
const hayResultados = computed(() =>
|
||||
aniosOrdenados.value.some((anio) => (resultadosPorAnio.value[anio] || []).length > 0)
|
||||
)
|
||||
|
||||
const linkResultados = (p) => `https://inscripciones.admision.unap.edu.pe/${p?.slug}/resultados`
|
||||
|
||||
const estadoTag = (p) => {
|
||||
return yaPasoFechaExamen(p?.fec_2, p?.fec_1)
|
||||
? { text: "FINALIZADO", color: "default" }
|
||||
: { text: "PRÓXIMAMENTE", color: "orange" }
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<NavbarModerno />
|
||||
|
||||
<section id="resultados" class="convocatorias-modern">
|
||||
<div class="section-container">
|
||||
<div class="section-header">
|
||||
<div class="header-with-badge">
|
||||
<h2 class="section-title">Resultados</h2>
|
||||
</div>
|
||||
<p class="section-subtitle">
|
||||
Consulta los resultados por año y proceso. Solo se muestran cuando el examen ya pasó.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Estados -->
|
||||
<a-card class="main-convocatoria-card" :loading="loading">
|
||||
<div class="card-badge">Resultados</div>
|
||||
|
||||
<template v-if="errorMsg">
|
||||
<div style="padding: 6px 2px; color: #dc2626; font-weight: 700;">
|
||||
{{ errorMsg }}
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-else-if="!hayResultados && !loading">
|
||||
<div style="padding: 6px 2px; color: #666;">
|
||||
Aún no hay resultados disponibles para mostrar.
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<div style="padding: 6px 2px; color:#666;">
|
||||
Resultados disponibles organizados por año:
|
||||
</div>
|
||||
</template>
|
||||
</a-card>
|
||||
|
||||
<!-- ✅ CADA AÑO ES SU PROPIA SECCIÓN / CARD -->
|
||||
<div v-for="anio in aniosOrdenados" :key="anio">
|
||||
<a-card
|
||||
v-if="(resultadosPorAnio[anio] || []).length"
|
||||
class="year-section-card"
|
||||
>
|
||||
<div class="year-header">
|
||||
<div class="year-icon">
|
||||
<CalendarOutlined />
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="year-title">Año {{ anio }}</h3>
|
||||
<p class="year-subtitle">Procesos con resultados disponibles del {{ anio }}.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<a-divider class="custom-divider" />
|
||||
|
||||
<!-- ✅ UNA SOLA COLUMNA -->
|
||||
<div class="secondary-list one-col">
|
||||
<a-card
|
||||
v-for="proceso in resultadosPorAnio[anio]"
|
||||
:key="proceso.id"
|
||||
class="secondary-convocatoria-card"
|
||||
>
|
||||
<div class="convocatoria-header">
|
||||
<div>
|
||||
<h4 class="secondary-title">
|
||||
EXAMEN {{ (proceso?.nombre ?? "proceso").toLowerCase() }}
|
||||
</h4>
|
||||
<p class="convocatoria-date">
|
||||
Examen:
|
||||
{{ formatFecha(proceso?.fec_2 || proceso?.fec_1) || (proceso?.fecha_examen ) }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<a-tag class="status-tag" :color="estadoTag(proceso).color">
|
||||
{{ estadoTag(proceso).text }}
|
||||
</a-tag>
|
||||
</div>
|
||||
|
||||
<div class="card-footer">
|
||||
|
||||
|
||||
<a-button type="primary" ghost size="small" :href="linkResultados(proceso)" target="_blank">
|
||||
<template #icon><FileSearchOutlined /></template>
|
||||
Ver Resultados
|
||||
</a-button>
|
||||
</div>
|
||||
</a-card>
|
||||
</div>
|
||||
</a-card>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<FooterModerno />
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* ====== BASE CONVOCATORIAS ====== */
|
||||
|
||||
.convocatorias-modern {
|
||||
position: relative;
|
||||
padding: 40px 0;
|
||||
font-family: "Times New Roman", Times, serif;
|
||||
background: #fbfcff;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.convocatorias-modern::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
z-index: 0;
|
||||
|
||||
background-image:
|
||||
repeating-linear-gradient(
|
||||
to right,
|
||||
rgba(13, 27, 82, 0.06) 0,
|
||||
rgba(13, 27, 82, 0.06) 1px,
|
||||
transparent 1px,
|
||||
transparent 24px
|
||||
),
|
||||
repeating-linear-gradient(
|
||||
to bottom,
|
||||
rgba(13, 27, 82, 0.06) 0,
|
||||
rgba(13, 27, 82, 0.06) 1px,
|
||||
transparent 1px,
|
||||
transparent 24px
|
||||
);
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
.section-container {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 0 24px;
|
||||
}
|
||||
|
||||
.section-header {
|
||||
text-align: center;
|
||||
margin-bottom: 50px;
|
||||
}
|
||||
|
||||
.header-with-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 2.6rem;
|
||||
font-weight: 700;
|
||||
color: #0d1b52;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.section-subtitle {
|
||||
font-size: 1.125rem;
|
||||
color: #666;
|
||||
max-width: 640px;
|
||||
margin: 14px auto 0;
|
||||
}
|
||||
|
||||
.new-badge :deep(.ant-badge-count) {
|
||||
background: linear-gradient(45deg, #ff6b6b, #ffd700);
|
||||
box-shadow: 0 2px 8px rgba(255, 107, 107, 0.3);
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
/* Card “estado” */
|
||||
.main-convocatoria-card {
|
||||
position: relative;
|
||||
border: none;
|
||||
box-shadow: 0 10px 34px rgba(0, 0, 0, 0.08);
|
||||
border-radius: 16px;
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
|
||||
.main-convocatoria-card :deep(.ant-card-body) {
|
||||
padding: 28px;
|
||||
}
|
||||
|
||||
.card-badge {
|
||||
position: absolute;
|
||||
top: -12px;
|
||||
left: 24px;
|
||||
background: linear-gradient(45deg, #1890ff, #52c41a);
|
||||
color: white;
|
||||
padding: 6px 16px;
|
||||
border-radius: 999px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
/* ✅ CARD POR AÑO */
|
||||
.year-section-card {
|
||||
border: none;
|
||||
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.07);
|
||||
border-radius: 16px;
|
||||
margin-top: 18px;
|
||||
}
|
||||
|
||||
.year-section-card :deep(.ant-card-body) {
|
||||
padding: 22px;
|
||||
}
|
||||
|
||||
.year-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.year-icon {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border-radius: 999px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
background: rgba(24, 144, 255, 0.12);
|
||||
color: #1890ff;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.year-title {
|
||||
margin: 0;
|
||||
font-size: 1.35rem;
|
||||
color: #1a237e;
|
||||
font-weight: 700;
|
||||
font-family: "Times New Roman", Times, serif;
|
||||
|
||||
}
|
||||
|
||||
.year-subtitle {
|
||||
margin: 4px 0 0;
|
||||
color: #666;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.custom-divider {
|
||||
margin: 18px 0;
|
||||
}
|
||||
|
||||
/* Cards internas */
|
||||
.secondary-convocatoria-card {
|
||||
border: none;
|
||||
box-shadow: 0 6px 18px rgba(0, 0, 0, 0.08);
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.secondary-convocatoria-card :deep(.ant-card-body) {
|
||||
padding: 18px;
|
||||
}
|
||||
|
||||
.convocatoria-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
gap: 14px;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.secondary-title {
|
||||
margin: 0;
|
||||
font-size: 1.05rem;
|
||||
color: #1a237e;
|
||||
text-transform: uppercase;
|
||||
font-family: "Times New Roman", Times, serif;
|
||||
}
|
||||
|
||||
.convocatoria-date {
|
||||
color: #666;
|
||||
margin: 6px 0 0;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.status-tag {
|
||||
font-weight: 700;
|
||||
padding: 4px 12px;
|
||||
border-radius: 999px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.convocatoria-desc {
|
||||
color: #666;
|
||||
line-height: 1.6;
|
||||
margin: 0 0 18px;
|
||||
}
|
||||
|
||||
.card-footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.card-footer > :last-child {
|
||||
margin-left: auto; /* 👉 empuja el último a la derecha */
|
||||
}
|
||||
|
||||
/* ✅ UNA SOLA COLUMNA SIEMPRE */
|
||||
.secondary-list.one-col {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
@media (max-width: 992px) {
|
||||
.section-title { font-size: 2.1rem; }
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.convocatorias-modern { padding: 55px 0; }
|
||||
}
|
||||
</style>
|
||||
@ -0,0 +1,49 @@
|
||||
import { defineStore } from "pinia"
|
||||
import api from "../axiosPostulante"
|
||||
|
||||
export const useNoticiasPublicasStore = defineStore("noticiasPublicas", {
|
||||
state: () => ({
|
||||
noticias: [],
|
||||
noticiaActual: null,
|
||||
loading: false,
|
||||
loadingOne: false,
|
||||
error: null,
|
||||
}),
|
||||
|
||||
actions: {
|
||||
async fetchNoticias() {
|
||||
this.loading = true
|
||||
this.error = null
|
||||
try {
|
||||
const res = await api.get("/noticias", {
|
||||
params: { publicado: true, per_page: 9999 },
|
||||
})
|
||||
this.noticias = res.data?.data ?? []
|
||||
} catch (err) {
|
||||
this.error = err.response?.data?.message || "Error al cargar noticias"
|
||||
this.noticias = []
|
||||
} finally {
|
||||
this.loading = false
|
||||
}
|
||||
},
|
||||
|
||||
async fetchNoticia(identifier) {
|
||||
this.loadingOne = true
|
||||
this.error = null
|
||||
this.noticiaActual = null
|
||||
try {
|
||||
const res = await api.get(`/noticias/${identifier}`)
|
||||
this.noticiaActual = res.data?.data ?? null
|
||||
} catch (err) {
|
||||
this.error = err.response?.data?.message || "Error al cargar la noticia"
|
||||
this.noticiaActual = null
|
||||
} finally {
|
||||
this.loadingOne = false
|
||||
}
|
||||
},
|
||||
|
||||
clearNoticiaActual() {
|
||||
this.noticiaActual = null
|
||||
},
|
||||
},
|
||||
})
|
||||
@ -0,0 +1,207 @@
|
||||
// src/store/noticiasStore.js
|
||||
import { defineStore } from "pinia"
|
||||
import api from "../axios" // <-- cambia a tu axios (admin) si tienes otro
|
||||
|
||||
export const useNoticiasStore = defineStore("noticias", {
|
||||
state: () => ({
|
||||
noticias: [],
|
||||
noticia: null,
|
||||
|
||||
loading: false,
|
||||
saving: false,
|
||||
deleting: false,
|
||||
|
||||
error: null,
|
||||
|
||||
// paginación (si tu back manda meta)
|
||||
meta: {
|
||||
current_page: 1,
|
||||
last_page: 1,
|
||||
per_page: 9,
|
||||
total: 0,
|
||||
},
|
||||
|
||||
// filtros
|
||||
filters: {
|
||||
publicado: null, // true/false/null
|
||||
categoria: "",
|
||||
q: "",
|
||||
per_page: 9,
|
||||
page: 1,
|
||||
},
|
||||
}),
|
||||
|
||||
getters: {
|
||||
// para el público: solo publicadas (si ya las filtras en backend, esto es opcional)
|
||||
publicadas: (state) => state.noticias.filter((n) => n.publicado),
|
||||
|
||||
// para ordenar en frontend (opcional)
|
||||
ordenadas: (state) =>
|
||||
[...state.noticias].sort((a, b) => {
|
||||
const da = a.fecha_publicacion ? new Date(a.fecha_publicacion).getTime() : 0
|
||||
const db = b.fecha_publicacion ? new Date(b.fecha_publicacion).getTime() : 0
|
||||
return db - da
|
||||
}),
|
||||
},
|
||||
|
||||
actions: {
|
||||
// ========= LISTAR =========
|
||||
async cargarNoticias(extraParams = {}) {
|
||||
this.loading = true
|
||||
this.error = null
|
||||
|
||||
try {
|
||||
const params = {
|
||||
per_page: this.filters.per_page,
|
||||
page: this.filters.page,
|
||||
...extraParams,
|
||||
}
|
||||
|
||||
if (this.filters.publicado !== null && this.filters.publicado !== "") {
|
||||
params.publicado = this.filters.publicado ? 1 : 0
|
||||
}
|
||||
if (this.filters.categoria) params.categoria = this.filters.categoria
|
||||
if (this.filters.q) params.q = this.filters.q
|
||||
|
||||
// Ruta agrupada:
|
||||
// GET /api/administracion/noticias
|
||||
const res = await api.get("/admin/noticias", { params })
|
||||
|
||||
this.noticias = res.data?.data ?? []
|
||||
this.meta = res.data?.meta ?? this.meta
|
||||
} catch (err) {
|
||||
this.error = err.response?.data?.message || "Error al cargar noticias"
|
||||
console.error(err)
|
||||
} finally {
|
||||
this.loading = false
|
||||
}
|
||||
},
|
||||
|
||||
// ========= VER 1 =========
|
||||
async cargarNoticia(id) {
|
||||
this.loading = true
|
||||
this.error = null
|
||||
try {
|
||||
const res = await api.get(`/admin/noticias/${id}`)
|
||||
this.noticia = res.data?.data ?? res.data
|
||||
} catch (err) {
|
||||
this.error = err.response?.data?.message || "Error al cargar la noticia"
|
||||
console.error(err)
|
||||
} finally {
|
||||
this.loading = false
|
||||
}
|
||||
},
|
||||
|
||||
// ========= CREAR =========
|
||||
// payload: { titulo, descripcion_corta, contenido, categoria, tag_color, fecha_publicacion, publicado, destacado, orden, link_url, link_texto, imagen(File) }
|
||||
async crearNoticia(payload) {
|
||||
this.saving = true
|
||||
this.error = null
|
||||
|
||||
try {
|
||||
const form = this._toFormData(payload)
|
||||
|
||||
const res = await api.post("/admin/noticias", form, {
|
||||
headers: { "Content-Type": "multipart/form-data" },
|
||||
})
|
||||
|
||||
const noticia = res.data?.data ?? null
|
||||
if (noticia) {
|
||||
// opcional: agrega arriba
|
||||
this.noticias = [noticia, ...this.noticias]
|
||||
}
|
||||
return noticia
|
||||
} catch (err) {
|
||||
this.error = err.response?.data?.message || "Error al crear noticia"
|
||||
console.error(err)
|
||||
throw err
|
||||
} finally {
|
||||
this.saving = false
|
||||
}
|
||||
},
|
||||
|
||||
// ========= ACTUALIZAR =========
|
||||
async actualizarNoticia(id, payload) {
|
||||
this.saving = true
|
||||
this.error = null
|
||||
|
||||
try {
|
||||
// Si hay imagen (File), conviene multipart
|
||||
const hasFile = payload?.imagen instanceof File
|
||||
let res
|
||||
|
||||
if (hasFile) {
|
||||
const form = this._toFormData(payload)
|
||||
|
||||
// Si tu ruta es PUT, axios con multipart PUT funciona.
|
||||
res = await api.put(`/admin/noticias/${id}`, form, {
|
||||
headers: { "Content-Type": "multipart/form-data" },
|
||||
})
|
||||
} else {
|
||||
// JSON normal
|
||||
res = await api.put(`/administracion/noticias/${id}`, payload)
|
||||
}
|
||||
|
||||
const updated = res.data?.data ?? null
|
||||
|
||||
if (updated) {
|
||||
this.noticias = this.noticias.map((n) => (n.id === id ? updated : n))
|
||||
if (this.noticia?.id === id) this.noticia = updated
|
||||
}
|
||||
|
||||
return updated
|
||||
} catch (err) {
|
||||
this.error = err.response?.data?.message || "Error al actualizar noticia"
|
||||
console.error(err)
|
||||
throw err
|
||||
} finally {
|
||||
this.saving = false
|
||||
}
|
||||
},
|
||||
|
||||
// ========= ELIMINAR =========
|
||||
async eliminarNoticia(id) {
|
||||
this.deleting = true
|
||||
this.error = null
|
||||
try {
|
||||
await api.delete(`/admin/noticias/${id}`)
|
||||
this.noticias = this.noticias.filter((n) => n.id !== id)
|
||||
if (this.noticia?.id === id) this.noticia = null
|
||||
return true
|
||||
} catch (err) {
|
||||
this.error = err.response?.data?.message || "Error al eliminar noticia"
|
||||
console.error(err)
|
||||
throw err
|
||||
} finally {
|
||||
this.deleting = false
|
||||
}
|
||||
},
|
||||
|
||||
// ========= Helpers =========
|
||||
setFiltro(key, value) {
|
||||
this.filters[key] = value
|
||||
},
|
||||
|
||||
resetFiltros() {
|
||||
this.filters = { publicado: null, categoria: "", q: "", per_page: 9, page: 1 }
|
||||
},
|
||||
|
||||
_toFormData(payload = {}) {
|
||||
const form = new FormData()
|
||||
|
||||
Object.entries(payload).forEach(([k, v]) => {
|
||||
if (v === undefined || v === null) return
|
||||
|
||||
// booleans como 1/0 (Laravel feliz)
|
||||
if (typeof v === "boolean") {
|
||||
form.append(k, v ? "1" : "0")
|
||||
return
|
||||
}
|
||||
|
||||
form.append(k, v)
|
||||
})
|
||||
|
||||
return form
|
||||
},
|
||||
},
|
||||
})
|
||||
@ -0,0 +1,41 @@
|
||||
// src/store/web.js
|
||||
import { defineStore } from "pinia"
|
||||
import api from "../axiosPostulante"
|
||||
|
||||
export const useWebAdmisionStore = defineStore("procesoAdmision", {
|
||||
state: () => ({
|
||||
procesos: [],
|
||||
loading: false,
|
||||
error: null,
|
||||
}),
|
||||
|
||||
getters: {
|
||||
// Si hay uno VIGENTE, úsalo como principal; si no, usa el primero.
|
||||
procesoPrincipal: (state) => {
|
||||
if (!state.procesos?.length) return null
|
||||
return state.procesos.find((p) => p.estado === "publicado") ?? state.procesos[0]
|
||||
},
|
||||
|
||||
// Por si lo necesitas después
|
||||
ultimoProceso: (state) => {
|
||||
return state.procesos?.length ? state.procesos[0] : null
|
||||
},
|
||||
},
|
||||
|
||||
actions: {
|
||||
async cargarProcesos() {
|
||||
this.loading = true
|
||||
this.error = null
|
||||
|
||||
try {
|
||||
const response = await api.get("/procesos-admision")
|
||||
this.procesos = response.data?.data ?? response.data ?? []
|
||||
} catch (err) {
|
||||
this.error = err.response?.data?.message || "Error al cargar procesos"
|
||||
console.error(err)
|
||||
} finally {
|
||||
this.loading = false
|
||||
}
|
||||
},
|
||||
},
|
||||
})
|
||||
@ -0,0 +1,124 @@
|
||||
<template>
|
||||
<section class="modalidades-section">
|
||||
<div class="section-container">
|
||||
<div class="section-header">
|
||||
<h1 class="section-title">404</h1>
|
||||
<p class="section-subtitle">
|
||||
La página que estás buscando no existe o fue movida.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="modalidades-grid">
|
||||
|
||||
<a-card class="modalidad-card">
|
||||
<div class="modalidad-icon bg-error">
|
||||
<WarningOutlined />
|
||||
</div>
|
||||
|
||||
<h4>Enlace incorrecto</h4>
|
||||
<p>
|
||||
Verifica que la dirección esté escrita correctamente
|
||||
o regresa al inicio.
|
||||
</p>
|
||||
|
||||
<a-button type="primary" @click="$router.push('/')">
|
||||
Volver al Inicio
|
||||
</a-button>
|
||||
</a-card>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { WarningOutlined } from "@ant-design/icons-vue"
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.modalidades-section {
|
||||
padding: 100px 0;
|
||||
background: #f8f9fa;
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.section-container {
|
||||
max-width: 900px;
|
||||
margin: 0 auto;
|
||||
padding: 0 24px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.section-header {
|
||||
text-align: center;
|
||||
margin-bottom: 50px;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 5rem;
|
||||
font-weight: 700;
|
||||
color: #1a237e;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.section-subtitle {
|
||||
font-size: 1.125rem;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.modalidades-grid {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.modalidad-card {
|
||||
border: none;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.08);
|
||||
text-align: center;
|
||||
padding: 40px 32px;
|
||||
transition: transform 0.3s ease;
|
||||
max-width: 420px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.modalidad-card:hover {
|
||||
transform: translateY(-4px);
|
||||
}
|
||||
|
||||
.modalidad-icon {
|
||||
width: 72px;
|
||||
height: 72px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin: 0 auto 24px;
|
||||
color: white;
|
||||
font-size: 32px;
|
||||
}
|
||||
|
||||
.bg-error {
|
||||
background: #ef4444;
|
||||
}
|
||||
|
||||
.modalidad-card h4 {
|
||||
margin: 0 0 12px;
|
||||
color: #1a237e;
|
||||
}
|
||||
|
||||
.modalidad-card p {
|
||||
color: #666;
|
||||
font-size: 0.95rem;
|
||||
line-height: 1.6;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
@media (max-width: 992px) {
|
||||
.section-title {
|
||||
font-size: 3rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@ -0,0 +1,181 @@
|
||||
<template>
|
||||
<div class="page-wrapper">
|
||||
<a-card :bordered="false" class="main-card">
|
||||
|
||||
<!-- Header -->
|
||||
<div class="header">
|
||||
<h2>Gestión de Calificaciones</h2>
|
||||
<a-button type="primary" @click="openModal()">
|
||||
Nueva Calificación
|
||||
</a-button>
|
||||
</div>
|
||||
|
||||
<!-- Tabla -->
|
||||
<a-table
|
||||
:columns="columns"
|
||||
:data-source="calificaciones"
|
||||
:loading="loading"
|
||||
row-key="id"
|
||||
bordered
|
||||
>
|
||||
<template #actions="{ record }">
|
||||
<a-space>
|
||||
<a-button type="link" @click="openModal(record)">
|
||||
Editar
|
||||
</a-button>
|
||||
|
||||
<a-popconfirm
|
||||
title="¿Seguro de eliminar?"
|
||||
@confirm="eliminar(record.id)"
|
||||
>
|
||||
<a-button type="link" danger>
|
||||
Eliminar
|
||||
</a-button>
|
||||
</a-popconfirm>
|
||||
</a-space>
|
||||
</template>
|
||||
</a-table>
|
||||
|
||||
</a-card>
|
||||
|
||||
<!-- Modal -->
|
||||
<a-modal
|
||||
v-model:open="modalVisible"
|
||||
:title="form.id ? 'Editar Calificación' : 'Nueva Calificación'"
|
||||
@ok="guardar"
|
||||
@cancel="cerrarModal"
|
||||
ok-text="Guardar"
|
||||
>
|
||||
<a-form layout="vertical">
|
||||
<a-form-item label="Nombre">
|
||||
<a-input v-model:value="form.nombre" />
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item label="Puntos Correcta">
|
||||
<a-input-number v-model:value="form.puntos_correcta" style="width:100%" />
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item label="Puntos Incorrecta">
|
||||
<a-input-number v-model:value="form.puntos_incorrecta" style="width:100%" />
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item label="Puntos Nula">
|
||||
<a-input-number v-model:value="form.puntos_nula" style="width:100%" />
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item label="Puntaje Máximo">
|
||||
<a-input-number v-model:value="form.puntaje_maximo" style="width:100%" />
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
</a-modal>
|
||||
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { message } from 'ant-design-vue'
|
||||
|
||||
import api from '../../../axios'
|
||||
|
||||
const calificaciones = ref([])
|
||||
const loading = ref(false)
|
||||
const modalVisible = ref(false)
|
||||
|
||||
const form = ref({
|
||||
id: null,
|
||||
nombre: '',
|
||||
puntos_correcta: 0,
|
||||
puntos_incorrecta: 0,
|
||||
puntos_nula: 0,
|
||||
puntaje_maximo: 0
|
||||
})
|
||||
|
||||
const columns = [
|
||||
{ title: 'Nombre', dataIndex: 'nombre' },
|
||||
{ title: 'Correcta', dataIndex: 'puntos_correcta' },
|
||||
{ title: 'Incorrecta', dataIndex: 'puntos_incorrecta' },
|
||||
{ title: 'Nula', dataIndex: 'puntos_nula' },
|
||||
{ title: 'Máximo', dataIndex: 'puntaje_maximo' },
|
||||
{ title: 'Acciones', key: 'actions', slots: { customRender: 'actions' } }
|
||||
]
|
||||
|
||||
const cargar = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const { data } = await api .getAll()
|
||||
calificaciones.value = data.data
|
||||
} catch (error) {
|
||||
message.error('Error al cargar')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const openModal = (record = null) => {
|
||||
if (record) {
|
||||
form.value = { ...record }
|
||||
} else {
|
||||
form.value = {
|
||||
id: null,
|
||||
nombre: '',
|
||||
puntos_correcta: 0,
|
||||
puntos_incorrecta: 0,
|
||||
puntos_nula: 0,
|
||||
puntaje_maximo: 0
|
||||
}
|
||||
}
|
||||
modalVisible.value = true
|
||||
}
|
||||
|
||||
const cerrarModal = () => {
|
||||
modalVisible.value = false
|
||||
}
|
||||
|
||||
const guardar = async () => {
|
||||
try {
|
||||
if (form.value.id) {
|
||||
await api .update(form.value.id, form.value)
|
||||
message.success('Actualizado correctamente')
|
||||
} else {
|
||||
await api .create(form.value)
|
||||
message.success('Creado correctamente')
|
||||
}
|
||||
|
||||
cerrarModal()
|
||||
cargar()
|
||||
} catch (error) {
|
||||
message.error('Error al guardar')
|
||||
}
|
||||
}
|
||||
|
||||
const eliminar = async (id) => {
|
||||
try {
|
||||
await api .delete(id)
|
||||
message.success('Eliminado correctamente')
|
||||
cargar()
|
||||
} catch (error) {
|
||||
message.error('Error al eliminar')
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
cargar()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.page-wrapper {
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.main-card {
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
</style>
|
||||
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,193 @@
|
||||
<template>
|
||||
<div class="postulantes-container">
|
||||
|
||||
<div class="header">
|
||||
<div>
|
||||
<h2 class="page-title">Gestión de Postulantes</h2>
|
||||
<p class="page-subtitle">Administra los usuarios registrados</p>
|
||||
</div>
|
||||
|
||||
<a-input-search
|
||||
v-model:value="search"
|
||||
placeholder="Buscar por nombre, email o DNI"
|
||||
style="width: 300px"
|
||||
@search="fetchPostulantes"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<a-card>
|
||||
<a-table
|
||||
:columns="columns"
|
||||
:data-source="postulantes"
|
||||
:loading="loading"
|
||||
:pagination="pagination"
|
||||
row-key="id"
|
||||
@change="handleTableChange"
|
||||
>
|
||||
<template #bodyCell="{ column, record }">
|
||||
|
||||
<template v-if="column.key === 'name'">
|
||||
<strong>{{ record.name }}</strong>
|
||||
</template>
|
||||
|
||||
<template v-else-if="column.key === 'email'">
|
||||
{{ record.email }}
|
||||
</template>
|
||||
|
||||
<template v-else-if="column.key === 'dni'">
|
||||
<a-tag color="blue">{{ record.dni }}</a-tag>
|
||||
</template>
|
||||
|
||||
<template v-else-if="column.key === 'last_activity'">
|
||||
{{ record.last_activity ?? 'Sin actividad' }}
|
||||
</template>
|
||||
|
||||
<template v-else-if="column.key === 'acciones'">
|
||||
<a-space>
|
||||
<a-button type="text" @click="editar(record)">
|
||||
Editar
|
||||
</a-button>
|
||||
</a-space>
|
||||
</template>
|
||||
|
||||
</template>
|
||||
</a-table>
|
||||
</a-card>
|
||||
|
||||
<a-modal
|
||||
v-model:open="showModal"
|
||||
title="Editar Postulante"
|
||||
@ok="guardarCambios"
|
||||
@cancel="resetForm"
|
||||
>
|
||||
<a-form layout="vertical">
|
||||
<a-form-item label="Nombre">
|
||||
<a-input v-model:value="form.name" />
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item label="Email">
|
||||
<a-input v-model:value="form.email" />
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item label="DNI">
|
||||
<a-input v-model:value="form.dni" />
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item label="Nueva contraseña (opcional)">
|
||||
<a-input-password v-model:value="form.password" />
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
</a-modal>
|
||||
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, onMounted } from 'vue'
|
||||
import { message } from 'ant-design-vue'
|
||||
import api from '../../../axios'
|
||||
|
||||
const postulantes = ref([])
|
||||
const loading = ref(false)
|
||||
const search = ref('')
|
||||
const showModal = ref(false)
|
||||
const editingId = ref(null)
|
||||
|
||||
const pagination = reactive({
|
||||
current: 1,
|
||||
pageSize: 20,
|
||||
total: 0
|
||||
})
|
||||
|
||||
const form = reactive({
|
||||
name: '',
|
||||
email: '',
|
||||
dni: '',
|
||||
password: ''
|
||||
})
|
||||
|
||||
const columns = [
|
||||
{ title: 'Nombre', dataIndex: 'name', key: 'name' },
|
||||
{ title: 'Email', dataIndex: 'email', key: 'email' },
|
||||
{ title: 'DNI', dataIndex: 'dni', key: 'dni' },
|
||||
{ title: 'Última actividad', dataIndex: 'last_activity', key: 'last_activity' },
|
||||
{ title: 'Acciones', key: 'acciones', align: 'center' }
|
||||
]
|
||||
|
||||
const fetchPostulantes = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const { data } = await api.get('/admin/postulantes', {
|
||||
params: {
|
||||
page: pagination.current,
|
||||
buscar: search.value
|
||||
}
|
||||
})
|
||||
|
||||
postulantes.value = data.data.data
|
||||
pagination.total = data.data.total
|
||||
|
||||
} catch (error) {
|
||||
message.error('Error al cargar postulantes')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const editar = (record) => {
|
||||
editingId.value = record.id
|
||||
form.name = record.name
|
||||
form.email = record.email
|
||||
form.dni = record.dni
|
||||
form.password = ''
|
||||
showModal.value = true
|
||||
}
|
||||
|
||||
|
||||
const guardarCambios = async () => {
|
||||
try {
|
||||
await api.put(`/admin/postulantes/${editingId.value}`, form)
|
||||
message.success('Postulante actualizado correctamente')
|
||||
showModal.value = false
|
||||
fetchPostulantes()
|
||||
} catch (error) {
|
||||
message.error(error.response?.data?.message || 'Error al actualizar')
|
||||
}
|
||||
}
|
||||
|
||||
const handleTableChange = (pag) => {
|
||||
pagination.current = pag.current
|
||||
fetchPostulantes()
|
||||
}
|
||||
|
||||
const resetForm = () => {
|
||||
showModal.value = false
|
||||
editingId.value = null
|
||||
}
|
||||
|
||||
onMounted(fetchPostulantes)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.postulantes-container {
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
margin: 0;
|
||||
font-size: 22px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.page-subtitle {
|
||||
font-size: 14px;
|
||||
color: #666;
|
||||
}
|
||||
</style>
|
||||
@ -0,0 +1,630 @@
|
||||
<!-- src/views/administracion/noticias/NoticiasAdmin.vue -->
|
||||
<template>
|
||||
<div class="areas-container">
|
||||
<!-- Header -->
|
||||
<div class="areas-header">
|
||||
<div class="header-title">
|
||||
<h2>Noticias</h2>
|
||||
<p class="subtitle">Gestión de noticias y comunicados</p>
|
||||
</div>
|
||||
|
||||
<a-button type="primary" @click="showCreateModal">
|
||||
<template #icon><PlusOutlined /></template>
|
||||
Nueva Noticia
|
||||
</a-button>
|
||||
</div>
|
||||
|
||||
<!-- Filtros -->
|
||||
<div class="filters-section">
|
||||
<a-input-search
|
||||
v-model:value="searchText"
|
||||
placeholder="Buscar por título o descripción..."
|
||||
@search="handleSearch"
|
||||
style="width: 340px"
|
||||
size="large"
|
||||
allowClear
|
||||
/>
|
||||
|
||||
<a-select
|
||||
v-model:value="publicadoFilter"
|
||||
placeholder="Publicado"
|
||||
style="width: 200px"
|
||||
size="large"
|
||||
@change="handleFilterChange"
|
||||
allowClear
|
||||
>
|
||||
<a-select-option :value="null">Todos</a-select-option>
|
||||
<a-select-option :value="true">Publicado</a-select-option>
|
||||
<a-select-option :value="false">Borrador</a-select-option>
|
||||
</a-select>
|
||||
|
||||
<a-input
|
||||
v-model:value="categoriaFilter"
|
||||
placeholder="Categoría (opcional)"
|
||||
style="width: 220px"
|
||||
size="large"
|
||||
allowClear
|
||||
@pressEnter="handleFilterChange"
|
||||
/>
|
||||
|
||||
<a-button size="large" @click="clearFilters">
|
||||
<ReloadOutlined /> Limpiar
|
||||
</a-button>
|
||||
</div>
|
||||
|
||||
<!-- Tabla -->
|
||||
<div class="areas-table-container">
|
||||
<a-table
|
||||
:data-source="noticiasStore.noticias"
|
||||
:columns="columns"
|
||||
:loading="noticiasStore.loading"
|
||||
:pagination="pagination"
|
||||
row-key="id"
|
||||
@change="handleTableChange"
|
||||
class="areas-table"
|
||||
>
|
||||
<template #bodyCell="{ column, record }">
|
||||
<!-- Imagen -->
|
||||
<template v-if="column.key === 'imagen'">
|
||||
<div class="thumb">
|
||||
<a-image
|
||||
v-if="record.imagen_url"
|
||||
:src="record.imagen_url"
|
||||
:preview="true"
|
||||
:width="56"
|
||||
:height="40"
|
||||
style="object-fit: cover; border-radius: 8px"
|
||||
/>
|
||||
<div v-else class="thumb-empty">Sin imagen</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Publicado -->
|
||||
<template v-if="column.key === 'publicado'">
|
||||
<a-tag :color="record.publicado ? 'green' : 'default'">
|
||||
{{ record.publicado ? "Publicado" : "Borrador" }}
|
||||
</a-tag>
|
||||
</template>
|
||||
|
||||
<!-- Destacado -->
|
||||
<template v-if="column.key === 'destacado'">
|
||||
<a-tag :color="record.destacado ? 'gold' : 'default'">
|
||||
{{ record.destacado ? "Destacado" : "Normal" }}
|
||||
</a-tag>
|
||||
</template>
|
||||
|
||||
<!-- Fecha -->
|
||||
<template v-if="column.key === 'fecha_publicacion'">
|
||||
{{ formatDate(record.fecha_publicacion) }}
|
||||
</template>
|
||||
|
||||
<!-- Acciones -->
|
||||
<template v-if="column.key === 'acciones'">
|
||||
<a-space>
|
||||
<a-button type="link" class="action-btn" @click="showEditModal(record)">
|
||||
<EditOutlined /> Editar
|
||||
</a-button>
|
||||
|
||||
<a-button danger type="link" class="action-btn" @click="confirmDelete(record)">
|
||||
<DeleteOutlined /> Eliminar
|
||||
</a-button>
|
||||
</a-space>
|
||||
</template>
|
||||
</template>
|
||||
</a-table>
|
||||
</div>
|
||||
|
||||
<!-- Modal Crear / Editar -->
|
||||
<a-modal
|
||||
v-model:open="modalVisible"
|
||||
:title="isEditing ? 'Editar Noticia' : 'Nueva Noticia'"
|
||||
:confirm-loading="noticiasStore.saving"
|
||||
@ok="handleSubmit"
|
||||
@cancel="closeModal"
|
||||
width="720px"
|
||||
class="area-modal"
|
||||
>
|
||||
<a-form ref="formRef" :model="formState" layout="vertical">
|
||||
<a-form-item label="Título" required>
|
||||
<a-input v-model:value="formState.titulo" placeholder="Ej: Comunicado oficial..." />
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item label="Descripción corta (para tarjetas)" required>
|
||||
<a-textarea
|
||||
v-model:value="formState.descripcion_corta"
|
||||
:rows="3"
|
||||
placeholder="Resumen breve (máx ~500 caracteres)"
|
||||
/>
|
||||
</a-form-item>
|
||||
|
||||
<a-row :gutter="16">
|
||||
<a-col :xs="24" :md="12">
|
||||
<a-form-item label="Categoría (opcional)">
|
||||
<a-input v-model:value="formState.categoria" placeholder="Ej: Resultados" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
|
||||
<a-col :xs="24" :md="12">
|
||||
<a-form-item label="Color del ribbon/tag (opcional)">
|
||||
<a-input v-model:value="formState.tag_color" placeholder="blue | red | green | gold ..." />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
</a-row>
|
||||
|
||||
<a-row :gutter="16">
|
||||
<a-col :xs="24" :md="12">
|
||||
<a-form-item label="Fecha de publicación (opcional)">
|
||||
<a-date-picker
|
||||
v-model:value="fechaPicker"
|
||||
style="width: 100%"
|
||||
format="DD/MM/YYYY"
|
||||
/>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
|
||||
<a-col :xs="24" :md="12">
|
||||
<a-form-item label="Orden (opcional)">
|
||||
<a-input-number v-model:value="formState.orden" style="width: 100%" :min="0" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
</a-row>
|
||||
|
||||
<a-row :gutter="16">
|
||||
<a-col :xs="24" :md="12">
|
||||
<a-form-item label="Publicado">
|
||||
<a-switch v-model:checked="formState.publicado" checked-children="Sí" un-checked-children="No" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
|
||||
<a-col :xs="24" :md="12">
|
||||
<a-form-item label="Destacado">
|
||||
<a-switch v-model:checked="formState.destacado" checked-children="Sí" un-checked-children="No" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
</a-row>
|
||||
|
||||
<a-form-item label="Contenido (opcional)">
|
||||
<a-textarea
|
||||
v-model:value="formState.contenido"
|
||||
:rows="6"
|
||||
placeholder="Contenido completo (si lo usarás en el detalle de la noticia)"
|
||||
/>
|
||||
</a-form-item>
|
||||
|
||||
<a-row :gutter="16">
|
||||
<a-col :xs="24" :md="16">
|
||||
<a-form-item label="Link (opcional)">
|
||||
<a-input v-model:value="formState.link_url" placeholder="https://..." />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
|
||||
<a-col :xs="24" :md="8">
|
||||
<a-form-item label="Texto del link (opcional)">
|
||||
<a-input v-model:value="formState.link_texto" placeholder="Ver más" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
</a-row>
|
||||
|
||||
<!-- Imagen -->
|
||||
<a-form-item label="Imagen (opcional)">
|
||||
<a-upload
|
||||
:before-upload="beforeUpload"
|
||||
:max-count="1"
|
||||
:show-upload-list="false"
|
||||
>
|
||||
<a-button>
|
||||
<UploadOutlined /> Seleccionar imagen
|
||||
</a-button>
|
||||
</a-upload>
|
||||
|
||||
<div class="image-preview" v-if="imagePreviewUrl || formState.imagen_url">
|
||||
<a-image
|
||||
:src="imagePreviewUrl || formState.imagen_url"
|
||||
:preview="true"
|
||||
style="border-radius: 12px; overflow: hidden"
|
||||
/>
|
||||
<a-button danger type="link" @click="clearImage" class="remove-image">
|
||||
Quitar imagen
|
||||
</a-button>
|
||||
</div>
|
||||
</a-form-item>
|
||||
|
||||
<a-alert
|
||||
v-if="noticiasStore.error"
|
||||
type="error"
|
||||
show-icon
|
||||
:message="noticiasStore.error"
|
||||
/>
|
||||
</a-form>
|
||||
</a-modal>
|
||||
|
||||
<!-- Modal Eliminar -->
|
||||
<a-modal
|
||||
v-model:open="deleteModalVisible"
|
||||
title="Eliminar Noticia"
|
||||
ok-type="danger"
|
||||
ok-text="Eliminar"
|
||||
:confirm-loading="noticiasStore.deleting"
|
||||
@ok="handleDelete"
|
||||
@cancel="deleteModalVisible = false"
|
||||
width="520px"
|
||||
>
|
||||
<a-alert type="warning" show-icon message="¿Deseas eliminar esta noticia?" />
|
||||
<div class="delete-info">
|
||||
<p><strong>{{ noticiaToDelete?.titulo }}</strong></p>
|
||||
<p class="muted">Esta acción no se puede deshacer.</p>
|
||||
</div>
|
||||
</a-modal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, onMounted, computed, watch } from "vue"
|
||||
import { message } from "ant-design-vue"
|
||||
import dayjs from "dayjs"
|
||||
|
||||
import {
|
||||
PlusOutlined,
|
||||
EditOutlined,
|
||||
DeleteOutlined,
|
||||
ReloadOutlined,
|
||||
UploadOutlined,
|
||||
} from "@ant-design/icons-vue"
|
||||
|
||||
import { useNoticiasStore } from "../../../store/noticiasStore"
|
||||
|
||||
const noticiasStore = useNoticiasStore()
|
||||
|
||||
const modalVisible = ref(false)
|
||||
const deleteModalVisible = ref(false)
|
||||
const isEditing = ref(false)
|
||||
const noticiaToDelete = ref(null)
|
||||
const formRef = ref()
|
||||
|
||||
const searchText = ref("")
|
||||
const publicadoFilter = ref(null)
|
||||
const categoriaFilter = ref("")
|
||||
|
||||
const fechaPicker = ref(null) // dayjs
|
||||
const imagePreviewUrl = ref("")
|
||||
const selectedImageFile = ref(null)
|
||||
|
||||
const formState = reactive({
|
||||
id: null,
|
||||
titulo: "",
|
||||
descripcion_corta: "",
|
||||
contenido: "",
|
||||
categoria: "",
|
||||
tag_color: "",
|
||||
link_url: "",
|
||||
link_texto: "",
|
||||
fecha_publicacion: null, // ISO string (opcional)
|
||||
publicado: false,
|
||||
destacado: false,
|
||||
orden: null,
|
||||
|
||||
// solo para mostrar cuando editas
|
||||
imagen_url: null,
|
||||
})
|
||||
|
||||
const pagination = computed(() => ({
|
||||
current: noticiasStore.meta?.current_page || 1,
|
||||
pageSize: noticiasStore.meta?.per_page || 9,
|
||||
total: noticiasStore.meta?.total || 0,
|
||||
showSizeChanger: true,
|
||||
}))
|
||||
|
||||
const columns = [
|
||||
{ title: "ID", dataIndex: "id", key: "id", width: 80 },
|
||||
{ title: "Imagen", key: "imagen", width: 90 },
|
||||
{ title: "Título", dataIndex: "titulo", key: "titulo" },
|
||||
{ title: "Categoría", dataIndex: "categoria", key: "categoria", width: 140 },
|
||||
{ title: "Publicado", dataIndex: "publicado", key: "publicado", width: 110 },
|
||||
{ title: "Destacado", dataIndex: "destacado", key: "destacado", width: 120 },
|
||||
{ title: "Fecha", dataIndex: "fecha_publicacion", key: "fecha_publicacion", width: 140 },
|
||||
{ title: "Acciones", key: "acciones", width: 180, align: "center" },
|
||||
]
|
||||
|
||||
const showCreateModal = () => {
|
||||
isEditing.value = false
|
||||
resetForm()
|
||||
modalVisible.value = true
|
||||
}
|
||||
|
||||
const showEditModal = (noticia) => {
|
||||
isEditing.value = true
|
||||
resetForm()
|
||||
|
||||
Object.assign(formState, {
|
||||
id: noticia.id,
|
||||
titulo: noticia.titulo || "",
|
||||
descripcion_corta: noticia.descripcion_corta || "",
|
||||
contenido: noticia.contenido || "",
|
||||
categoria: noticia.categoria || "",
|
||||
tag_color: noticia.tag_color || "",
|
||||
link_url: noticia.link_url || "",
|
||||
link_texto: noticia.link_texto || "",
|
||||
fecha_publicacion: noticia.fecha_publicacion || null,
|
||||
publicado: !!noticia.publicado,
|
||||
destacado: !!noticia.destacado,
|
||||
orden: noticia.orden ?? null,
|
||||
imagen_url: noticia.imagen_url || null,
|
||||
})
|
||||
|
||||
fechaPicker.value = noticia.fecha_publicacion ? dayjs(noticia.fecha_publicacion) : null
|
||||
|
||||
modalVisible.value = true
|
||||
}
|
||||
|
||||
const closeModal = () => {
|
||||
modalVisible.value = false
|
||||
resetForm()
|
||||
}
|
||||
|
||||
const resetForm = () => {
|
||||
Object.assign(formState, {
|
||||
id: null,
|
||||
titulo: "",
|
||||
descripcion_corta: "",
|
||||
contenido: "",
|
||||
categoria: "",
|
||||
tag_color: "",
|
||||
link_url: "",
|
||||
link_texto: "",
|
||||
fecha_publicacion: null,
|
||||
publicado: false,
|
||||
destacado: false,
|
||||
orden: null,
|
||||
imagen_url: null,
|
||||
})
|
||||
|
||||
fechaPicker.value = null
|
||||
imagePreviewUrl.value = ""
|
||||
selectedImageFile.value = null
|
||||
}
|
||||
|
||||
watch(fechaPicker, (v) => {
|
||||
formState.fecha_publicacion = v ? v.toISOString() : null
|
||||
})
|
||||
|
||||
const beforeUpload = (file) => {
|
||||
// preview local
|
||||
selectedImageFile.value = file
|
||||
imagePreviewUrl.value = URL.createObjectURL(file)
|
||||
message.success("Imagen seleccionada")
|
||||
// IMPORTANTE: evitar auto-upload (lo hacemos con el submit)
|
||||
return false
|
||||
}
|
||||
|
||||
const clearImage = () => {
|
||||
selectedImageFile.value = null
|
||||
imagePreviewUrl.value = ""
|
||||
// si quieres que al actualizar se borre imagen, envía imagen_path = null
|
||||
// aquí solo la quito del preview; el back no borra a menos que lo programes.
|
||||
}
|
||||
|
||||
const handleSubmit = async () => {
|
||||
try {
|
||||
if (!formState.titulo?.trim() || !formState.descripcion_corta?.trim()) {
|
||||
message.warning("Completa al menos Título y Descripción corta.")
|
||||
return
|
||||
}
|
||||
|
||||
const payload = {
|
||||
titulo: formState.titulo,
|
||||
descripcion_corta: formState.descripcion_corta,
|
||||
contenido: formState.contenido || null,
|
||||
categoria: formState.categoria || null,
|
||||
tag_color: formState.tag_color || null,
|
||||
link_url: formState.link_url || null,
|
||||
link_texto: formState.link_texto || null,
|
||||
fecha_publicacion: formState.fecha_publicacion || null,
|
||||
publicado: formState.publicado,
|
||||
destacado: formState.destacado,
|
||||
orden: formState.orden,
|
||||
}
|
||||
|
||||
if (selectedImageFile.value) payload.imagen = selectedImageFile.value
|
||||
|
||||
if (isEditing.value) {
|
||||
await noticiasStore.actualizarNoticia(formState.id, payload)
|
||||
message.success("Noticia actualizada")
|
||||
} else {
|
||||
await noticiasStore.crearNoticia(payload)
|
||||
message.success("Noticia creada")
|
||||
}
|
||||
|
||||
closeModal()
|
||||
await fetchTable()
|
||||
} catch (e) {
|
||||
message.error("Error al guardar")
|
||||
}
|
||||
}
|
||||
|
||||
const confirmDelete = (noticia) => {
|
||||
noticiaToDelete.value = noticia
|
||||
deleteModalVisible.value = true
|
||||
}
|
||||
|
||||
const handleDelete = async () => {
|
||||
try {
|
||||
await noticiasStore.eliminarNoticia(noticiaToDelete.value.id)
|
||||
message.success("Noticia eliminada")
|
||||
deleteModalVisible.value = false
|
||||
await fetchTable()
|
||||
} catch {
|
||||
message.error("Error al eliminar")
|
||||
}
|
||||
}
|
||||
|
||||
const handleSearch = async () => {
|
||||
noticiasStore.setFiltro("q", searchText.value)
|
||||
noticiasStore.setFiltro("page", 1)
|
||||
await fetchTable()
|
||||
}
|
||||
|
||||
const handleFilterChange = async () => {
|
||||
noticiasStore.setFiltro("publicado", publicadoFilter.value)
|
||||
noticiasStore.setFiltro("categoria", categoriaFilter.value)
|
||||
noticiasStore.setFiltro("page", 1)
|
||||
await fetchTable()
|
||||
}
|
||||
|
||||
const clearFilters = async () => {
|
||||
searchText.value = ""
|
||||
publicadoFilter.value = null
|
||||
categoriaFilter.value = ""
|
||||
noticiasStore.resetFiltros()
|
||||
await fetchTable()
|
||||
}
|
||||
|
||||
const handleTableChange = async (pg) => {
|
||||
noticiasStore.setFiltro("page", pg.current)
|
||||
noticiasStore.setFiltro("per_page", pg.pageSize)
|
||||
await fetchTable()
|
||||
}
|
||||
|
||||
const fetchTable = async () => {
|
||||
await noticiasStore.cargarNoticias()
|
||||
}
|
||||
|
||||
const formatDate = (date) => {
|
||||
if (!date) return "-"
|
||||
const d = new Date(date)
|
||||
if (isNaN(d.getTime())) return "-"
|
||||
return d.toLocaleDateString("es-PE")
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await fetchTable()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.areas-container {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.areas-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;
|
||||
}
|
||||
|
||||
.filters-section {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-bottom: 24px;
|
||||
padding: 0 8px;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.areas-table-container {
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
|
||||
border: 1px solid #f0f0f0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.areas-table :deep(.ant-table-thead > tr > th) {
|
||||
background: #fafafa;
|
||||
font-weight: 600;
|
||||
color: #1f1f1f;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
}
|
||||
|
||||
.areas-table :deep(.ant-table-tbody > tr > td) {
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
}
|
||||
|
||||
.areas-table :deep(.ant-table-tbody > tr:hover > td) {
|
||||
background: #fafafa;
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
padding: 4px 8px;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.thumb {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.thumb-empty {
|
||||
width: 56px;
|
||||
height: 40px;
|
||||
border-radius: 8px;
|
||||
border: 1px dashed #d9d9d9;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.image-preview {
|
||||
margin-top: 10px;
|
||||
border: 1px solid #f0f0f0;
|
||||
border-radius: 14px;
|
||||
padding: 10px;
|
||||
background: #fafafa;
|
||||
}
|
||||
.remove-image {
|
||||
padding: 0;
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
.delete-info {
|
||||
margin-top: 12px;
|
||||
}
|
||||
.muted {
|
||||
color: #777;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* Responsive */
|
||||
@media (max-width: 768px) {
|
||||
.areas-header {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.filters-section {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.filters-section .ant-input-search,
|
||||
.filters-section .ant-select,
|
||||
.filters-section .ant-input,
|
||||
.filters-section .ant-btn {
|
||||
width: 100% !important;
|
||||
}
|
||||
|
||||
.areas-table-container {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.areas-table {
|
||||
min-width: 980px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
Loading…
Reference in New Issue