main
Elmer Yujra Condori 2 months ago
parent e84ec16504
commit 71182fd292

@ -0,0 +1,199 @@
<?php
namespace App\Http\Controllers\Administracion;
use App\Http\Controllers\Controller;
use App\Models\ProcesoAdmision;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
use Illuminate\Validation\Rule;
class ProcesoAdmisionController extends Controller
{
// GET /api/admin/procesos-admision?include_detalles=1&q=...&estado=...&publicado=1&page=1&per_page=15
public function index(Request $request)
{
$q = ProcesoAdmision::query();
if ($request->filled('q')) {
$term = $request->string('q');
$q->where(function ($sub) use ($term) {
$sub->where('titulo', 'like', "%{$term}%")
->orWhere('slug', 'like', "%{$term}%");
});
}
if ($request->filled('estado')) {
$q->where('estado', $request->string('estado'));
}
if ($request->filled('publicado')) {
$q->where('publicado', (bool) $request->boolean('publicado'));
}
if ($request->boolean('include_detalles')) {
$q->with(['detalles' => fn($d) => $d->orderBy('id', 'desc')]);
}
$q->orderByDesc('id');
$perPage = (int) $request->input('per_page', 15);
return response()->json($q->paginate($perPage));
}
// GET /api/admin/procesos-admision/{id}?include_detalles=1
public function show(Request $request, $id)
{
$q = ProcesoAdmision::query();
if ($request->boolean('include_detalles')) {
$q->with('detalles');
}
return response()->json($q->findOrFail($id));
}
// POST /api/admin/procesos-admision (multipart/form-data)
public function store(Request $request)
{
$data = $request->validate([
'titulo' => ['required','string','max:255'],
'subtitulo' => ['nullable','string','max:255'],
'descripcion' => ['nullable','string'],
'slug' => ['nullable','string','max:120', 'unique:procesos_admision,slug'],
'tipo_proceso' => ['nullable','string','max:60'],
'modalidad' => ['nullable','string','max:50'],
'publicado' => ['sometimes','boolean'],
'fecha_publicacion' => ['nullable','date'],
'fecha_inicio_preinscripcion' => ['nullable','date'],
'fecha_fin_preinscripcion' => ['nullable','date','after_or_equal:fecha_inicio_preinscripcion'],
'fecha_inicio_inscripcion' => ['nullable','date'],
'fecha_fin_inscripcion' => ['nullable','date','after_or_equal:fecha_inicio_inscripcion'],
'fecha_examen1' => ['nullable','date'],
'fecha_examen2' => ['nullable','date','after_or_equal:fecha_examen1'],
'fecha_resultados' => ['nullable','date'],
'fecha_inicio_biometrico' => ['nullable','date'],
'fecha_fin_biometrico' => ['nullable','date','after_or_equal:fecha_inicio_biometrico'],
'link_preinscripcion' => ['nullable','string','max:500'],
'link_inscripcion' => ['nullable','string','max:500'],
'link_resultados' => ['nullable','string','max:500'],
'link_reglamento' => ['nullable','string','max:500'],
'estado' => ['sometimes', Rule::in(['nuevo','publicado','en_proceso','finalizado','cancelado'])],
// Archivos (uploads)
'imagen' => ['nullable','image','mimes:jpg,jpeg,png,webp','max:10240'],
'banner' => ['nullable','image','mimes:jpg,jpeg,png,webp','max:10240'],
'brochure' => ['nullable','file','mimes:pdf','max:10240'],
]);
// slug auto si no viene
if (empty($data['slug'])) {
$data['slug'] = Str::slug($data['titulo']);
}
$data['publicado'] = $data['publicado'] ?? false;
// Guardar archivos
if ($request->hasFile('imagen')) {
$data['imagen_path'] = $request->file('imagen')->store('admisiones/procesos', 'public');
}
if ($request->hasFile('banner')) {
$data['banner_path'] = $request->file('banner')->store('admisiones/procesos', 'public');
}
if ($request->hasFile('brochure')) {
$data['brochure_path'] = $request->file('brochure')->store('admisiones/procesos', 'public');
}
$proceso = ProcesoAdmision::create($data);
return response()->json($proceso, 201);
}
// PATCH /api/admin/procesos-admision/{id} (multipart/form-data)
public function update(Request $request, $id)
{
$proceso = ProcesoAdmision::findOrFail($id);
$data = $request->validate([
'titulo' => ['sometimes','string','max:255'],
'subtitulo' => ['sometimes','nullable','string','max:255'],
'descripcion' => ['sometimes','nullable','string'],
'slug' => ['sometimes','nullable','string','max:120', Rule::unique('procesos_admision','slug')->ignore($proceso->id)],
'tipo_proceso' => ['sometimes','nullable','string','max:60'],
'modalidad' => ['sometimes','nullable','string','max:50'],
'publicado' => ['sometimes','boolean'],
'fecha_publicacion' => ['sometimes','nullable','date'],
'fecha_inicio_preinscripcion' => ['sometimes','nullable','date'],
'fecha_fin_preinscripcion' => ['sometimes','nullable','date','after_or_equal:fecha_inicio_preinscripcion'],
'fecha_inicio_inscripcion' => ['sometimes','nullable','date'],
'fecha_fin_inscripcion' => ['sometimes','nullable','date','after_or_equal:fecha_inicio_inscripcion'],
'fecha_examen1' => ['sometimes','nullable','date'],
'fecha_examen2' => ['sometimes','nullable','date','after_or_equal:fecha_examen1'],
'fecha_resultados' => ['sometimes','nullable','date'],
'fecha_inicio_biometrico' => ['sometimes','nullable','date'],
'fecha_fin_biometrico' => ['sometimes','nullable','date','after_or_equal:fecha_inicio_biometrico'],
'link_preinscripcion' => ['sometimes','nullable','string','max:500'],
'link_inscripcion' => ['sometimes','nullable','string','max:500'],
'link_resultados' => ['sometimes','nullable','string','max:500'],
'link_reglamento' => ['sometimes','nullable','string','max:500'],
'estado' => ['sometimes', Rule::in(['nuevo','publicado','en_proceso','finalizado','cancelado'])],
// Archivos
'imagen' => ['sometimes','nullable','image','mimes:jpg,jpeg,png,webp','max:10240'],
'banner' => ['sometimes','nullable','image','mimes:jpg,jpeg,png,webp','max:10240'],
'brochure' => ['sometimes','nullable','file','mimes:pdf','max:10240'],
]);
// slug auto si lo mandan vacío
if (array_key_exists('slug', $data) && empty($data['slug'])) {
$data['slug'] = Str::slug($data['titulo'] ?? $proceso->titulo);
}
// Reemplazo de archivos (borra anterior)
if ($request->hasFile('imagen')) {
if ($proceso->imagen_path) Storage::disk('public')->delete($proceso->imagen_path);
$data['imagen_path'] = $request->file('imagen')->store('admisiones/procesos', 'public');
}
if ($request->hasFile('banner')) {
if ($proceso->banner_path) Storage::disk('public')->delete($proceso->banner_path);
$data['banner_path'] = $request->file('banner')->store('admisiones/procesos', 'public');
}
if ($request->hasFile('brochure')) {
if ($proceso->brochure_path) Storage::disk('public')->delete($proceso->brochure_path);
$data['brochure_path'] = $request->file('brochure')->store('admisiones/procesos', 'public');
}
$proceso->update($data);
return response()->json($proceso->fresh());
}
// DELETE /api/admin/procesos-admision/{id}
public function destroy($id)
{
$proceso = ProcesoAdmision::findOrFail($id);
// Borrar archivos asociados
if ($proceso->imagen_path) Storage::disk('public')->delete($proceso->imagen_path);
if ($proceso->banner_path) Storage::disk('public')->delete($proceso->banner_path);
if ($proceso->brochure_path) Storage::disk('public')->delete($proceso->brochure_path);
$proceso->delete();
return response()->json(['message' => 'Proceso eliminado']);
}
}

@ -0,0 +1,113 @@
<?php
namespace App\Http\Controllers\Administracion;
use App\Http\Controllers\Controller;
use App\Models\ProcesoAdmision;
use App\Models\ProcesoAdmisionDetalle;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Storage;
use Illuminate\Validation\Rule;
class ProcesoAdmisionDetalleController extends Controller
{
// GET /api/admin/procesos-admision/{procesoId}/detalles?tipo=requisitos
public function index(Request $request, $procesoId)
{
ProcesoAdmision::findOrFail($procesoId);
$q = ProcesoAdmisionDetalle::query()->where('proceso_admision_id', $procesoId);
if ($request->filled('tipo')) {
$q->where('tipo', $request->string('tipo'));
}
return response()->json($q->orderByDesc('id')->get());
}
// POST /api/admin/procesos-admision/{procesoId}/detalles (multipart/form-data)
public function store(Request $request, $procesoId)
{
ProcesoAdmision::findOrFail($procesoId);
$data = $request->validate([
'tipo' => ['required', Rule::in(['requisitos','pagos','vacantes','cronograma'])],
'titulo_detalle' => ['required','string','max:255'],
'descripcion' => ['nullable','string'],
'listas' => ['nullable','array'],
'meta' => ['nullable','array'],
'url' => ['nullable','string','max:500'],
'imagen' => ['nullable','image','mimes:jpg,jpeg,png,webp','max:10240'],
'imagen_2' => ['nullable','image','mimes:jpg,jpeg,png,webp','max:10240'],
]);
$data['proceso_admision_id'] = (int) $procesoId;
if ($request->hasFile('imagen')) {
$data['imagen_path'] = $request->file('imagen')->store('admisiones/detalles', 'public');
}
if ($request->hasFile('imagen_2')) {
$data['imagen_path_2'] = $request->file('imagen_2')->store('admisiones/detalles', 'public');
}
$detalle = ProcesoAdmisionDetalle::create($data);
return response()->json($detalle, 201);
}
// GET /api/admin/detalles-admision/{id}
public function show($id)
{
return response()->json(ProcesoAdmisionDetalle::findOrFail($id));
}
// PATCH /api/admin/detalles-admision/{id} (multipart/form-data)
public function update(Request $request, $id)
{
$detalle = ProcesoAdmisionDetalle::findOrFail($id);
$data = $request->validate([
'tipo' => ['sometimes', Rule::in(['requisitos','pagos','vacantes','cronograma'])],
'titulo_detalle' => ['sometimes','string','max:255'],
'descripcion' => ['sometimes','nullable','string'],
'listas' => ['sometimes','nullable','array'],
'meta' => ['sometimes','nullable','array'],
'url' => ['sometimes','nullable','string','max:500'],
'imagen' => ['sometimes','nullable','image','mimes:jpg,jpeg,png,webp','max:10240'],
'imagen_2' => ['sometimes','nullable','image','mimes:jpg,jpeg,png,webp','max:10240'],
]);
if ($request->hasFile('imagen')) {
if ($detalle->imagen_path) Storage::disk('public')->delete($detalle->imagen_path);
$data['imagen_path'] = $request->file('imagen')->store('admisiones/detalles', 'public');
}
if ($request->hasFile('imagen_2')) {
if ($detalle->imagen_path_2) Storage::disk('public')->delete($detalle->imagen_path_2);
$data['imagen_path_2'] = $request->file('imagen_2')->store('admisiones/detalles', 'public');
}
$detalle->update($data);
return response()->json($detalle->fresh());
}
// DELETE /api/admin/detalles-admision/{id}
public function destroy($id)
{
$detalle = ProcesoAdmisionDetalle::findOrFail($id);
if ($detalle->imagen_path) Storage::disk('public')->delete($detalle->imagen_path);
if ($detalle->imagen_path_2) Storage::disk('public')->delete($detalle->imagen_path_2);
$detalle->delete();
return response()->json(['message' => 'Detalle eliminado']);
}
}

@ -3,33 +3,67 @@
namespace App\Models; namespace App\Models;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Support\Facades\Storage;
class ProcesoAdmision extends Model class ProcesoAdmision extends Model
{ {
protected $table = 'procesos_admision'; protected $table = 'procesos_admision';
protected $fillable = [ protected $fillable = [
'nombre', 'titulo','subtitulo','descripcion','slug',
'fecha_inicio', 'tipo_proceso','modalidad',
'fecha_fin', 'publicado','fecha_publicacion',
'estado' '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',
]; ];
protected $casts = [ protected $casts = [
'fecha_inicio' => 'datetime', 'publicado' => 'boolean',
'fecha_fin' => 'datetime', 'fecha_publicacion' => 'datetime',
'estado' => 'boolean' 'fecha_inicio_preinscripcion' => 'datetime',
'fecha_fin_preinscripcion' => 'datetime',
'fecha_inicio_inscripcion' => 'datetime',
'fecha_fin_inscripcion' => 'datetime',
'fecha_examen1' => 'datetime',
'fecha_examen2' => 'datetime',
'fecha_resultados' => 'datetime',
'fecha_inicio_biometrico' => 'datetime',
'fecha_fin_biometrico' => 'datetime',
]; ];
/* protected $appends = ['imagen_url','banner_url','brochure_url'];
|--------------------------------------------------------------------------
| RELACIONES
|--------------------------------------------------------------------------
*/
// Un proceso tiene muchos resultados public function detalles(): HasMany
{
return $this->hasMany(ProcesoAdmisionDetalle::class, 'proceso_admision_id');
}
public function getImagenUrlAttribute(): ?string
{
return $this->imagen_path ? Storage::disk('public')->url($this->imagen_path) : null;
}
public function getBannerUrlAttribute(): ?string
{
return $this->banner_path ? Storage::disk('public')->url($this->banner_path) : null;
}
public function getBrochureUrlAttribute(): ?string
{
return $this->brochure_path ? Storage::disk('public')->url($this->brochure_path) : null;
}
// Un proceso tiene muchos resultados
public function resultados() public function resultados()
{ {
return $this->hasMany(ResultadoAdmision::class, 'idproceso'); return $this->hasMany(ResultadoAdmision::class, 'idproceso');
} }
} }

@ -0,0 +1,46 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Support\Facades\Storage;
class ProcesoAdmisionDetalle extends Model
{
protected $table = 'proceso_admision_detalles';
protected $fillable = [
'proceso_admision_id',
'tipo',
'titulo_detalle',
'descripcion',
'listas',
'meta',
'url',
'imagen_path',
'imagen_path_2',
];
protected $casts = [
'listas' => 'array',
'meta' => 'array',
];
protected $appends = ['imagen_url','imagen_url_2'];
public function proceso(): BelongsTo
{
return $this->belongsTo(ProcesoAdmision::class, 'proceso_admision_id');
}
public function getImagenUrlAttribute(): ?string
{
return $this->imagen_path ? Storage::disk('public')->url($this->imagen_path) : null;
}
public function getImagenUrl2Attribute(): ?string
{
return $this->imagen_path_2 ? Storage::disk('public')->url($this->imagen_path_2) : null;
}
}

@ -14,7 +14,9 @@ use App\Http\Controllers\Administracion\ProcesoController;
use App\Http\Controllers\PostulanteAuthController; use App\Http\Controllers\PostulanteAuthController;
use App\Http\Controllers\ExamenController; use App\Http\Controllers\ExamenController;
use App\Http\Controllers\Administracion\ReglaAreaProcesoController; use App\Http\Controllers\Administracion\ReglaAreaProcesoController;
use App\Http\Controllers\Administracion\ProcesoAdmisionController;
use App\Http\Controllers\Administracion\ProcesoAdmisionDetalleController;
use App\Models\ProcesoAdmisionDetalle;
Route::get('/user', function (Request $request) { Route::get('/user', function (Request $request) {
return $request->user(); return $request->user();
@ -133,7 +135,7 @@ Route::middleware(['auth:sanctum'])->prefix('reglas')->group(function () {
// Examen - Flujo separado // Examen - Flujo separado
Route::middleware(['auth:postulante'])->group(function () { Route::middleware(['auth:postulante'])->group(function () {
// Configuración
Route::get('/examen/procesos', [ExamenController::class, 'procesoexamen']); Route::get('/examen/procesos', [ExamenController::class, 'procesoexamen']);
Route::get('/examen/areas', [ExamenController::class, 'areas']); Route::get('/examen/areas', [ExamenController::class, 'areas']);
Route::get('/examen/actual', [ExamenController::class, 'miExamenActual']); Route::get('/examen/actual', [ExamenController::class, 'miExamenActual']);
@ -156,4 +158,28 @@ Route::middleware(['auth:postulante'])->group(function () {
// Finalizar examen // Finalizar examen
Route::post('/examen/{examen}/finalizar', [ExamenController::class, 'finalizarExamen']); Route::post('/examen/{examen}/finalizar', [ExamenController::class, 'finalizarExamen']);
}); });
Route::middleware('auth:sanctum')->prefix('admin')->group(function () {
// PROCESOS
Route::prefix('procesos-admision')->group(function () {
Route::get('/', [ProcesoAdmisionController::class, 'index'])->name('index');
Route::post('/', [ProcesoAdmisionController::class, 'store'])->name('store');
Route::get('/{id}', [ProcesoAdmisionController::class, 'show'])->name('show');
Route::match(['put','patch'], '/{id}', [ProcesoAdmisionController::class, 'update'])->name('update');
Route::delete('/{id}', [ProcesoAdmisionController::class, 'destroy'])->name('destroy');
// DETALLES por proceso
Route::get('/{procesoId}/detalles', [ProcesoAdmisionDetalleController::class, 'index'])->name('detalles.index');
Route::post('/{procesoId}/detalles', [ProcesoAdmisionDetalleController::class, 'store'])->name('detalles.store');
});
// DETALLES por ID
Route::prefix('detalles-admision')->group(function () {
Route::get('/{id}', [ProcesoAdmisionDetalleController::class, 'show'])->name('show');
Route::match(['put','patch'], '/{id}', [ProcesoAdmisionDetalleController::class, 'update'])->name('update');
Route::delete('/{id}', [ProcesoAdmisionDetalleController::class, 'destroy'])->name('destroy');
});
});

@ -118,6 +118,17 @@ const routes = [
name: 'Reglas', name: 'Reglas',
component: () => import('../views/administrador/Procesos/ReglasList.vue'), component: () => import('../views/administrador/Procesos/ReglasList.vue'),
meta: { requiresAuth: true, role: 'administrador' } meta: { requiresAuth: true, role: 'administrador' }
},
{
path: '/admin/dashboard/procesos-admision',
name: 'ProcesosAdmisionList',
component: () => import('../views/administrador/procesoadmision/ProcesosAdmisionList.vue')
},
{
path: '/admin/dashboard/procesos/:id/detalles',
name: 'ProcesoAdmisionDetalles',
component: () => import('../views/administrador/procesoadmision/ProcesoAdmisionDetalles.vue')
} }
] ]
}, },

@ -0,0 +1,276 @@
// src/store/procesoAdmision.store.js
import { defineStore } from 'pinia'
import api from '../axios'
export const useProcesoAdmisionStore = defineStore('procesoAdmision', {
state: () => ({
loading: false,
error: null,
// listado
procesos: [],
pagination: {
current_page: 1,
per_page: 15,
total: 0,
last_page: 1
},
// detalle
procesoActual: null,
// detalles del proceso actual
detalles: []
}),
actions: {
_setError(err) {
this.error =
err?.response?.data?.message ||
err?.response?.data?.error ||
err?.message ||
'Ocurrió un error'
},
// =========================
// PROCESOS
// =========================
// GET /api/admin/procesos-admision
async fetchProcesos(params = {}) {
this.loading = true
this.error = null
try {
const { data } = await api.get('/admin/procesos-admision', {
params: {
per_page: this.pagination.per_page,
...params
}
})
// Laravel paginate
this.procesos = data.data ?? []
this.pagination.current_page = data.current_page ?? 1
this.pagination.per_page = data.per_page ?? this.pagination.per_page
this.pagination.total = data.total ?? this.procesos.length
this.pagination.last_page = data.last_page ?? 1
return true
} catch (err) {
this._setError(err)
return false
} finally {
this.loading = false
}
},
// GET /api/admin/procesos-admision/{id}?include_detalles=1
async fetchProceso(id, { include_detalles = true } = {}) {
this.loading = true
this.error = null
try {
const { data } = await api.get(`/admin/procesos-admision/${id}`, {
params: { include_detalles: include_detalles ? 1 : 0 }
})
this.procesoActual = data
this.detalles = Array.isArray(data.detalles) ? data.detalles : []
return true
} catch (err) {
this._setError(err)
return false
} finally {
this.loading = false
}
},
// POST /api/admin/procesos-admision (multipart/form-data)
async createProceso(payload) {
// payload puede ser objeto normal o FormData
this.loading = true
this.error = null
try {
const isFormData = payload instanceof FormData
await api.post('/admin/procesos-admision', payload, {
headers: isFormData ? { 'Content-Type': 'multipart/form-data' } : undefined
})
// refrescar lista
await this.fetchProcesos({ page: 1 })
return true
} catch (err) {
this._setError(err)
return false
} finally {
this.loading = false
}
},
// PATCH /api/admin/procesos-admision/{id} (multipart/form-data)
async updateProceso(id, payload, { method = 'patch' } = {}) {
this.loading = true
this.error = null
try {
const isFormData = payload instanceof FormData
const { data } = await api.request({
url: `/admin/procesos-admision/${id}`,
method,
data: payload,
headers: isFormData ? { 'Content-Type': 'multipart/form-data' } : undefined
})
// actualizar cache local
if (this.procesoActual?.id === id) {
this.procesoActual = data
}
// refrescar listado (opcional, pero recomendado)
await this.fetchProcesos({
page: this.pagination.current_page,
per_page: this.pagination.per_page
})
return true
} catch (err) {
this._setError(err)
return false
} finally {
this.loading = false
}
},
// DELETE /api/admin/procesos-admision/{id}
async deleteProceso(id) {
this.loading = true
this.error = null
try {
await api.delete(`/admin/procesos-admision/${id}`)
// limpiar caches
this.procesos = this.procesos.filter(p => p.id !== id)
if (this.procesoActual?.id === id) {
this.procesoActual = null
this.detalles = []
}
return true
} catch (err) {
this._setError(err)
return false
} finally {
this.loading = false
}
},
// =========================
// DETALLES (POR PROCESO)
// =========================
// GET /api/admin/procesos-admision/{procesoId}/detalles?tipo=requisitos
async fetchDetalles(procesoId, params = {}) {
this.loading = true
this.error = null
try {
const { data } = await api.get(`/admin/procesos-admision/${procesoId}/detalles`, {
params
})
this.detalles = Array.isArray(data) ? data : []
return true
} catch (err) {
this._setError(err)
return false
} finally {
this.loading = false
}
},
// POST /api/admin/procesos-admision/{procesoId}/detalles (multipart/form-data)
async createDetalle(procesoId, payload) {
this.loading = true
this.error = null
try {
const isFormData = payload instanceof FormData
await api.post(`/admin/procesos-admision/${procesoId}/detalles`, payload, {
headers: isFormData ? { 'Content-Type': 'multipart/form-data' } : undefined
})
await this.fetchDetalles(procesoId)
return true
} catch (err) {
this._setError(err)
return false
} finally {
this.loading = false
}
},
// GET /api/admin/detalles-admision/{id}
async fetchDetalleById(detalleId) {
this.loading = true
this.error = null
try {
const { data } = await api.get(`/admin/detalles-admision/${detalleId}`)
return data
} catch (err) {
this._setError(err)
return null
} finally {
this.loading = false
}
},
// PATCH /api/admin/detalles-admision/{id} (multipart/form-data)
async updateDetalle(detalleId, payload, { method = 'patch' } = {}) {
this.loading = true
this.error = null
try {
const isFormData = payload instanceof FormData
const { data } = await api.request({
url: `/admin/detalles-admision/${detalleId}`,
method,
data: payload,
headers: isFormData ? { 'Content-Type': 'multipart/form-data' } : undefined
})
// actualizar cache local si existe
const idx = this.detalles.findIndex(d => d.id === data.id)
if (idx >= 0) this.detalles[idx] = data
return true
} catch (err) {
this._setError(err)
return false
} finally {
this.loading = false
}
},
// DELETE /api/admin/detalles-admision/{id}
async deleteDetalle(detalleId, procesoId = null) {
this.loading = true
this.error = null
try {
await api.delete(`/admin/detalles-admision/${detalleId}`)
this.detalles = this.detalles.filter(d => d.id !== detalleId)
// si me pasas procesoId, refresca desde backend
if (procesoId) await this.fetchDetalles(procesoId)
return true
} catch (err) {
this._setError(err)
return false
} finally {
this.loading = false
}
}
}
})

@ -201,25 +201,20 @@
</a-menu-item> </a-menu-item>
</a-sub-menu> </a-sub-menu>
<a-sub-menu key="preguntas" class="sub-menu"> <a-sub-menu key="procesos" class="sub-menu">
<template #title> <template #title>
<div class="sub-menu-title"> <div class="sub-menu-title">
<QuestionCircleOutlined class="menu-icon" /> <QuestionCircleOutlined class="menu-icon" />
<span class="menu-label">Preguntas</span> <span class="menu-label">Procesos</span>
</div> </div>
</template> </template>
<a-menu-item key="lista-areas" class="menu-item"> <a-menu-item key="procesos-lista" class="menu-item">
<div class="menu-item-content"> <div class="menu-item-content">
<AppstoreOutlined class="menu-icon" /> <AppstoreOutlined class="menu-icon" />
<span class="menu-label">Áreas</span> <span class="menu-label">Procesos Lista</span>
</div>
</a-menu-item>
<a-menu-item key="lista-cursos" class="menu-item">
<div class="menu-item-content">
<BookOutlined class="menu-icon" />
<span class="menu-label">Cursos</span>
</div> </div>
</a-menu-item> </a-menu-item>
</a-sub-menu> </a-sub-menu>
<!-- Análisis --> <!-- Análisis -->
@ -426,7 +421,7 @@ const handleMenuSelect = ({ key }) => {
'examenes-reglas-lista': { name: 'Reglas' }, 'examenes-reglas-lista': { name: 'Reglas' },
'lista-areas': { name: 'AcademiaAreas' }, 'procesos-lista': { name: 'ProcesosAdmisionList' },
'lista-cursos': { name: 'AcademiaCursos' }, 'lista-cursos': { name: 'AcademiaCursos' },
'resultados': { name: 'AcademiaResultados' }, 'resultados': { name: 'AcademiaResultados' },
'reportes': { name: 'AcademiaReportes' }, 'reportes': { name: 'AcademiaReportes' },

@ -0,0 +1,357 @@
<template>
<div class="cursos-container">
<div class="cursos-header">
<div class="header-title">
<h2>Detalles del Proceso</h2>
<p class="subtitle">Proceso: {{ store.procesoActual?.titulo || '-' }}</p>
</div>
<a-space>
<a-button @click="goBack"><ArrowLeftOutlined /> Volver</a-button>
<a-button type="primary" @click="openCreateModal"><PlusOutlined /> Nuevo Detalle</a-button>
</a-space>
</div>
<div class="filters-section">
<a-select v-model:value="tipoFilter" placeholder="Tipo" style="width: 240px" size="large" @change="fetchDetalles">
<a-select-option :value="null">Todos</a-select-option>
<a-select-option value="requisitos">requisitos</a-select-option>
<a-select-option value="pagos">pagos</a-select-option>
<a-select-option value="vacantes">vacantes</a-select-option>
<a-select-option value="cronograma">cronograma</a-select-option>
</a-select>
<a-button @click="clearFilters" size="large">
<ReloadOutlined /> Limpiar
</a-button>
</div>
<div v-if="store.loading && store.detalles.length === 0" class="loading-state">
<a-spin size="large" />
<p>Cargando detalles...</p>
</div>
<div v-else-if="store.detalles.length === 0" class="empty-state">
<a-empty description="No hay detalles" />
</div>
<div v-else class="cursos-table-container">
<a-table :data-source="store.detalles" :columns="columns" :loading="store.loading" row-key="id" class="cursos-table">
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'imagenes'">
<a-space>
<a v-if="record.imagen_url" :href="record.imagen_url" target="_blank">Imagen 1</a>
<a v-if="record.imagen_url_2" :href="record.imagen_url_2" target="_blank">Imagen 2</a>
<span v-if="!record.imagen_url && !record.imagen_url_2">-</span>
</a-space>
</template>
<template v-else-if="column.key === 'acciones'">
<a-space>
<a-button type="link" size="small" @click="editDetalle(record)"><EditOutlined /> Editar</a-button>
<a-button type="link" size="small" danger @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 Detalle' : 'Nuevo Detalle'"
:confirm-loading="store.loading"
@ok="handleModalOk"
@cancel="handleModalCancel"
width="920px"
class="curso-modal"
>
<a-form ref="formRef" :model="formState" :rules="formRules" layout="vertical" @finish="onFormSubmit">
<div class="grid2">
<a-form-item label="Tipo" name="tipo">
<a-select v-model:value="formState.tipo" size="large" :disabled="isEditing">
<a-select-option value="requisitos">requisitos</a-select-option>
<a-select-option value="pagos">pagos</a-select-option>
<a-select-option value="vacantes">vacantes</a-select-option>
<a-select-option value="cronograma">cronograma</a-select-option>
</a-select>
</a-form-item>
<a-form-item label="Título" name="titulo_detalle">
<a-input v-model:value="formState.titulo_detalle" size="large" />
</a-form-item>
</div>
<a-form-item label="Descripción" name="descripcion">
<a-textarea v-model:value="formState.descripcion" :rows="3" />
</a-form-item>
<div class="grid2">
<a-form-item label="URL (opcional)" name="url">
<a-input v-model:value="formState.url" placeholder="https://..." size="large" />
</a-form-item>
<a-form-item label="Listas (JSON)" name="listas_json">
<a-textarea v-model:value="formState.listas_json" :rows="8" placeholder='Ej: [{"titulo":"DNI","descripcion":"..."}]' />
</a-form-item>
</div>
<a-form-item label="Meta (JSON)" name="meta_json">
<a-textarea v-model:value="formState.meta_json" :rows="6" placeholder='Ej: {"moneda":"PEN"}' />
</a-form-item>
<a-divider>Imágenes (SUBIR)</a-divider>
<div class="grid2">
<a-form-item label="Imagen 1">
<a-upload v-model:file-list="fileImg1" :before-upload="() => false" :max-count="1" accept="image/*">
<a-button>Seleccionar</a-button>
</a-upload>
<small class="hint" v-if="isEditing && formState.imagen_url">
Actual: <a :href="formState.imagen_url" target="_blank">ver</a>
</small>
</a-form-item>
<a-form-item label="Imagen 2">
<a-upload v-model:file-list="fileImg2" :before-upload="() => false" :max-count="1" accept="image/*">
<a-button>Seleccionar</a-button>
</a-upload>
<small class="hint" v-if="isEditing && formState.imagen_url_2">
Actual: <a :href="formState.imagen_url_2" target="_blank">ver</a>
</small>
</a-form-item>
</div>
<div class="form-footer" v-if="store.error">
<a-alert type="error" :message="store.error" show-icon class="error-alert" />
</div>
</a-form>
</a-modal>
<!-- Delete modal -->
<a-modal
v-model:open="deleteModalVisible"
title="Confirmar Eliminación"
@ok="handleDelete"
@cancel="deleteModalVisible = false"
ok-text="Eliminar"
cancel-text="Cancelar"
ok-type="danger"
width="420px"
>
<a-alert message="¿Eliminar este detalle?" type="warning" show-icon />
<div class="curso-info" v-if="toDelete" style="margin-top:12px;">
<p><strong>Tipo:</strong> {{ toDelete.tipo }}</p>
<p><strong>Título:</strong> {{ toDelete.titulo_detalle }}</p>
</div>
</a-modal>
</div>
</template>
<script setup>
import { ref, reactive, onMounted, computed } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { message } from 'ant-design-vue'
import { PlusOutlined, EditOutlined, DeleteOutlined, ReloadOutlined, ArrowLeftOutlined } from '@ant-design/icons-vue'
import { useProcesoAdmisionStore } from '../../../store/procesosAdmisionStore'
const store = useProcesoAdmisionStore()
const route = useRoute()
const router = useRouter()
const procesoId = computed(() => Number(route.params.id))
const tipoFilter = ref(null)
const modalVisible = ref(false)
const deleteModalVisible = ref(false)
const isEditing = ref(false)
const toDelete = ref(null)
const formRef = ref()
const fileImg1 = ref([])
const fileImg2 = ref([])
const formState = reactive({
id: null,
tipo: 'requisitos',
titulo_detalle: '',
descripcion: '',
url: '',
listas_json: '',
meta_json: '',
imagen_url: null,
imagen_url_2: null
})
const formRules = {
tipo: [{ required: true, message: 'Selecciona tipo', trigger: 'change' }],
titulo_detalle: [{ required: true, message: 'Ingresa título', trigger: 'blur' }]
}
const columns = [
{ title: 'ID', dataIndex: 'id', key: 'id', width: 70 },
{ title: 'Tipo', dataIndex: 'tipo', key: 'tipo', width: 130 },
{ title: 'Título', dataIndex: 'titulo_detalle', key: 'titulo_detalle' },
{ title: 'Imágenes', key: 'imagenes', width: 180 },
{ title: 'Acciones', key: 'acciones', width: 160, align: 'center' }
]
function goBack() {
router.push({ name: 'ProcesosAdmisionList' })
}
function clearFilters() {
tipoFilter.value = null
fetchDetalles()
}
async function fetchDetalles() {
await store.fetchDetalles(procesoId.value, {
tipo: tipoFilter.value || undefined
})
}
function resetUploads() {
fileImg1.value = []
fileImg2.value = []
}
function resetForm() {
formState.id = null
formState.tipo = 'requisitos'
formState.titulo_detalle = ''
formState.descripcion = ''
formState.url = ''
formState.listas_json = ''
formState.meta_json = ''
formState.imagen_url = null
formState.imagen_url_2 = null
resetUploads()
formRef.value?.resetFields?.()
}
function openCreateModal() {
isEditing.value = false
resetForm()
modalVisible.value = true
}
function editDetalle(row) {
isEditing.value = true
resetForm()
formState.id = row.id
formState.tipo = row.tipo
formState.titulo_detalle = row.titulo_detalle
formState.descripcion = row.descripcion || ''
formState.url = row.url || ''
formState.listas_json = row.listas ? JSON.stringify(row.listas, null, 2) : ''
formState.meta_json = row.meta ? JSON.stringify(row.meta, null, 2) : ''
formState.imagen_url = row.imagen_url || null
formState.imagen_url_2 = row.imagen_url_2 || null
modalVisible.value = true
}
async function handleModalOk() {
try {
await formRef.value.validateFields()
await onFormSubmit()
} catch (_) {}
}
function handleModalCancel() {
modalVisible.value = false
resetForm()
}
function safeJson(text, label) {
if (!text || !text.trim()) return null
try {
return JSON.parse(text)
} catch {
message.error(`JSON inválido en ${label}`)
throw new Error('json_invalid')
}
}
function buildDetalleFormData() {
const fd = new FormData()
fd.append('tipo', formState.tipo)
fd.append('titulo_detalle', formState.titulo_detalle)
if (formState.descripcion) fd.append('descripcion', formState.descripcion)
if (formState.url) fd.append('url', formState.url)
const listas = safeJson(formState.listas_json, 'listas')
const meta = safeJson(formState.meta_json, 'meta')
// 👇 OJO: backend valida 'array' para listas/meta (Laravel)
// Para FormData, lo más compatible es enviarlo como JSON string y en backend hacer json_decode.
// Si tu backend ya espera array directo, entonces NO uses FormData para listas/meta.
// Solución simple: envía como string y decodifica en controller.
if (listas !== null) fd.append('listas', JSON.stringify(listas))
if (meta !== null) fd.append('meta', JSON.stringify(meta))
const img1 = fileImg1.value?.[0]?.originFileObj
const img2 = fileImg2.value?.[0]?.originFileObj
if (img1) fd.append('imagen', img1)
if (img2) fd.append('imagen_2', img2)
return fd
}
async function onFormSubmit() {
const fd = buildDetalleFormData()
// method spoof para update multipart
if (isEditing.value) {
fd.append('_method', 'PATCH')
const ok = await store.updateDetalle(formState.id, fd, { method: 'post' })
if (ok) message.success('Detalle actualizado')
else message.error('Error al actualizar')
} else {
const ok = await store.createDetalle(procesoId.value, fd)
if (ok) message.success('Detalle creado')
else message.error('Error al crear')
}
modalVisible.value = false
resetForm()
await fetchDetalles()
}
function confirmDelete(row) {
toDelete.value = row
deleteModalVisible.value = true
}
async function handleDelete() {
const ok = await store.deleteDetalle(toDelete.value.id, procesoId.value)
if (ok) message.success('Detalle eliminado')
else message.error('Error al eliminar')
deleteModalVisible.value = false
toDelete.value = null
}
onMounted(async () => {
await store.fetchProceso(procesoId.value, { include_detalles: false })
await fetchDetalles()
})
</script>
<style scoped>
.cursos-container { padding: 0; }
.cursos-header { display:flex; justify-content:space-between; align-items:center; margin-bottom:24px; padding:0 8px; gap: 12px; flex-wrap: wrap; }
.header-title h2 { margin:0; font-size:24px; font-weight:600; color:#1f1f1f; }
.subtitle { margin:4px 0 0; color:#666; font-size:14px; }
.filters-section { display:flex; align-items:center; margin-bottom:18px; padding:0 8px; flex-wrap:wrap; gap:16px; }
.loading-state { display:flex; flex-direction:column; align-items:center; justify-content:center; min-height:220px; gap:16px; }
.empty-state { min-height:220px; display:flex; align-items:center; justify-content:center; }
.cursos-table-container { background:white; border-radius:12px; box-shadow:0 2px 8px rgba(0,0,0,.06); border:1px solid #f0f0f0; overflow:hidden; }
.cursos-table { border-radius:12px; }
.grid2 { display:grid; grid-template-columns: 1fr 1fr; gap: 14px; }
.grid3 { display:grid; grid-template-columns: 1fr 1fr 1fr; gap: 14px; }
.hint { display:block; margin-top: 6px; color:#666; }
@media (max-width: 768px) { .grid2, .grid3 { grid-template-columns: 1fr; } }
</style>

@ -0,0 +1,764 @@
<template>
<div class="cursos-container">
<!-- Header -->
<div class="cursos-header">
<div class="header-title">
<h2>Procesos de Admisión</h2>
<p class="subtitle">Crea procesos y luego gestiona sus detalles</p>
</div>
<a-button type="primary" @click="showCreateModal" class="new-curso-btn">
<template #icon><PlusOutlined /></template>
Nuevo Proceso
</a-button>
</div>
<!-- Filtros -->
<div class="filters-section">
<a-input-search
v-model:value="searchText"
placeholder="Buscar por título o slug..."
@search="handleSearch"
@press-enter="handleSearch"
style="width: 320px"
size="large"
>
<template #enterButton><SearchOutlined /></template>
</a-input-search>
<a-select
v-model:value="estadoFilter"
placeholder="Estado"
style="width: 220px"
size="large"
@change="handleFiltersChange"
>
<a-select-option :value="null">Todos</a-select-option>
<a-select-option value="nuevo">Nuevo</a-select-option>
<a-select-option value="publicado">Publicado</a-select-option>
<a-select-option value="en_proceso">En proceso</a-select-option>
<a-select-option value="finalizado">Finalizado</a-select-option>
<a-select-option value="cancelado">Cancelado</a-select-option>
</a-select>
<a-select
v-model:value="publicadoFilter"
placeholder="Publicado"
style="width: 180px"
size="large"
@change="handleFiltersChange"
>
<a-select-option :value="null">Todos</a-select-option>
<a-select-option :value="true"></a-select-option>
<a-select-option :value="false">No</a-select-option>
</a-select>
<a-button @click="handleClearFilters" size="large">
<template #icon><ReloadOutlined /></template>
Limpiar
</a-button>
</div>
<!-- Loading -->
<div v-if="store.loading && store.procesos.length === 0" class="loading-state">
<a-spin size="large" />
<p>Cargando procesos...</p>
</div>
<!-- Empty -->
<div v-else-if="store.procesos.length === 0" class="empty-state">
<a-empty description="No hay procesos registrados" />
</div>
<!-- Tabla -->
<div v-else class="cursos-table-container">
<a-table
:data-source="store.procesos"
:columns="columns"
:loading="store.loading"
:pagination="pagination"
row-key="id"
@change="handleTableChange"
class="cursos-table"
>
<template #bodyCell="{ column, record }">
<!-- Publicado -->
<template v-if="column.key === 'publicado'">
<a-switch
:checked="!!record.publicado"
:loading="togglingId === record.id"
checked-children="Sí"
un-checked-children="No"
@change="togglePublicado(record)"
/>
</template>
<!-- Fechas -->
<template v-else-if="column.key === 'fechas'">
<div class="mini-dates">
<div>
<strong>Preinscripción:</strong>
{{ shortDate(record.fecha_inicio_preinscripcion) }} - {{ shortDate(record.fecha_fin_preinscripcion) }}
</div>
<div>
<strong>Inscripción:</strong>
{{ shortDate(record.fecha_inicio_inscripcion) }} - {{ shortDate(record.fecha_fin_inscripcion) }}
</div>
<div>
<strong>Ex1:</strong> {{ shortDate(record.fecha_examen1) }}
<span v-if="record.fecha_examen2"> | <strong>Ex2:</strong> {{ shortDate(record.fecha_examen2) }}</span>
</div>
</div>
</template>
<!-- Archivos -->
<template v-else-if="column.key === 'imagenes'">
<a-space>
<a v-if="record.imagen_url" :href="record.imagen_url" target="_blank">Imagen</a>
<a v-if="record.banner_url" :href="record.banner_url" target="_blank">Banner</a>
<a v-if="record.brochure_url" :href="record.brochure_url" target="_blank">Brochure</a>
<span v-if="!record.imagen_url && !record.banner_url && !record.brochure_url">-</span>
</a-space>
</template>
<!-- Created -->
<template v-else-if="column.key === 'created_at'">
{{ formatDate(record.created_at) }}
</template>
<!-- Acciones -->
<template v-else-if="column.key === 'acciones'">
<a-space>
<a-button type="link" size="small" @click="goDetalles(record)">
<SettingOutlined /> Detalles
</a-button>
<a-button type="link" size="small" @click="showEditModal(record)">
<EditOutlined /> Editar
</a-button>
<a-button type="link" size="small" danger @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 Proceso' : 'Nuevo Proceso'"
:confirm-loading="store.loading"
@ok="handleModalOk"
@cancel="handleModalCancel"
width="980px"
class="curso-modal"
>
<a-form ref="formRef" :model="formState" :rules="formRules" layout="vertical" @finish="onFormSubmit">
<div class="grid2">
<a-form-item label="Título" name="titulo">
<a-input v-model:value="formState.titulo" size="large" />
</a-form-item>
<a-form-item label="Slug" name="slug">
<a-input v-model:value="formState.slug" placeholder="admision-2026-i" size="large" />
</a-form-item>
</div>
<div class="grid2">
<a-form-item label="Subtítulo" name="subtitulo">
<a-input v-model:value="formState.subtitulo" size="large" />
</a-form-item>
<a-form-item label="Tipo proceso" name="tipo_proceso">
<a-input v-model:value="formState.tipo_proceso" placeholder="Ej: admisión general" size="large" />
</a-form-item>
</div>
<div class="grid2">
<a-form-item label="Modalidad (texto)" name="modalidad">
<a-input v-model:value="formState.modalidad" placeholder="Ej: ordinario" size="large" />
</a-form-item>
<a-form-item label="Estado" name="estado">
<a-select v-model:value="formState.estado" size="large">
<a-select-option value="nuevo">Nuevo</a-select-option>
<a-select-option value="publicado">Publicado</a-select-option>
<a-select-option value="en_proceso">En proceso</a-select-option>
<a-select-option value="finalizado">Finalizado</a-select-option>
<a-select-option value="cancelado">Cancelado</a-select-option>
</a-select>
</a-form-item>
</div>
<a-form-item label="Descripción" name="descripcion">
<a-textarea v-model:value="formState.descripcion" :rows="3" />
</a-form-item>
<div class="grid2">
<a-form-item label="Publicado" name="publicado">
<a-switch v-model:checked="formState.publicado" checked-children="Sí" un-checked-children="No" />
</a-form-item>
<a-form-item label="Fecha publicación" name="fecha_publicacion">
<a-date-picker
v-model:value="formState.fecha_publicacion"
show-time
style="width: 100%"
size="large"
format="YYYY-MM-DD HH:mm:ss"
valueFormat="YYYY-MM-DD HH:mm:ss"
placeholder="Opcional"
/>
</a-form-item>
</div>
<a-divider>Fechas</a-divider>
<!-- PREINSCRIPCIÓN -->
<div class="grid2">
<a-form-item label="Inicio preinscripción" name="fecha_inicio_preinscripcion">
<a-date-picker
v-model:value="formState.fecha_inicio_preinscripcion"
show-time
style="width: 100%"
size="large"
format="YYYY-MM-DD HH:mm:ss"
valueFormat="YYYY-MM-DD HH:mm:ss"
placeholder="Opcional"
/>
</a-form-item>
<a-form-item label="Fin preinscripción" name="fecha_fin_preinscripcion">
<a-date-picker
v-model:value="formState.fecha_fin_preinscripcion"
show-time
style="width: 100%"
size="large"
format="YYYY-MM-DD HH:mm:ss"
valueFormat="YYYY-MM-DD HH:mm:ss"
placeholder="Opcional"
/>
</a-form-item>
</div>
<!-- INSCRIPCIÓN -->
<div class="grid2">
<a-form-item label="Inicio inscripción" name="fecha_inicio_inscripcion">
<a-date-picker
v-model:value="formState.fecha_inicio_inscripcion"
show-time
style="width: 100%"
size="large"
format="YYYY-MM-DD HH:mm:ss"
valueFormat="YYYY-MM-DD HH:mm:ss"
/>
</a-form-item>
<a-form-item label="Fin inscripción" name="fecha_fin_inscripcion">
<a-date-picker
v-model:value="formState.fecha_fin_inscripcion"
show-time
style="width: 100%"
size="large"
format="YYYY-MM-DD HH:mm:ss"
valueFormat="YYYY-MM-DD HH:mm:ss"
/>
</a-form-item>
</div>
<!-- EXÁMENES -->
<div class="grid2">
<a-form-item label="Examen 1" name="fecha_examen1">
<a-date-picker
v-model:value="formState.fecha_examen1"
show-time
style="width: 100%"
size="large"
format="YYYY-MM-DD HH:mm:ss"
valueFormat="YYYY-MM-DD HH:mm:ss"
/>
</a-form-item>
<a-form-item label="Examen 2" name="fecha_examen2">
<a-date-picker
v-model:value="formState.fecha_examen2"
show-time
style="width: 100%"
size="large"
format="YYYY-MM-DD HH:mm:ss"
valueFormat="YYYY-MM-DD HH:mm:ss"
placeholder="Opcional"
/>
</a-form-item>
</div>
<!-- RESULTADOS / BIOMÉTRICO -->
<div class="grid2">
<a-form-item label="Resultados" name="fecha_resultados">
<a-date-picker
v-model:value="formState.fecha_resultados"
show-time
style="width: 100%"
size="large"
format="YYYY-MM-DD HH:mm:ss"
valueFormat="YYYY-MM-DD HH:mm:ss"
placeholder="Opcional"
/>
</a-form-item>
<a-form-item label="Inicio biométrico" name="fecha_inicio_biometrico">
<a-date-picker
v-model:value="formState.fecha_inicio_biometrico"
show-time
style="width: 100%"
size="large"
format="YYYY-MM-DD HH:mm:ss"
valueFormat="YYYY-MM-DD HH:mm:ss"
placeholder="Opcional"
/>
</a-form-item>
</div>
<div class="grid2">
<a-form-item label="Fin biométrico" name="fecha_fin_biometrico">
<a-date-picker
v-model:value="formState.fecha_fin_biometrico"
show-time
style="width: 100%"
size="large"
format="YYYY-MM-DD HH:mm:ss"
valueFormat="YYYY-MM-DD HH:mm:ss"
placeholder="Opcional"
/>
</a-form-item>
<div />
</div>
<a-divider>Archivos (SUBIR)</a-divider>
<div class="grid3">
<a-form-item label="Imagen (jpg/png/webp)">
<a-upload v-model:file-list="fileImagen" :before-upload="() => false" :max-count="1" accept="image/*">
<a-button>Seleccionar</a-button>
</a-upload>
<small class="hint" v-if="isEditing && formState.imagen_url">
Actual: <a :href="formState.imagen_url" target="_blank">ver</a>
</small>
</a-form-item>
<a-form-item label="Banner (jpg/png/webp)">
<a-upload v-model:file-list="fileBanner" :before-upload="() => false" :max-count="1" accept="image/*">
<a-button>Seleccionar</a-button>
</a-upload>
<small class="hint" v-if="isEditing && formState.banner_url">
Actual: <a :href="formState.banner_url" target="_blank">ver</a>
</small>
</a-form-item>
<a-form-item label="Brochure (PDF)">
<a-upload v-model:file-list="fileBrochure" :before-upload="() => false" :max-count="1" accept="application/pdf">
<a-button>Seleccionar</a-button>
</a-upload>
<small class="hint" v-if="isEditing && formState.brochure_url">
Actual: <a :href="formState.brochure_url" target="_blank">ver</a>
</small>
</a-form-item>
</div>
<a-divider>Links</a-divider>
<div class="grid2">
<a-form-item label="Link preinscripción" name="link_preinscripcion">
<a-input v-model:value="formState.link_preinscripcion" placeholder="https://..." size="large" />
</a-form-item>
<a-form-item label="Link inscripción" name="link_inscripcion">
<a-input v-model:value="formState.link_inscripcion" placeholder="https://..." size="large" />
</a-form-item>
</div>
<div class="grid2">
<a-form-item label="Link resultados" name="link_resultados">
<a-input v-model:value="formState.link_resultados" placeholder="https://..." size="large" />
</a-form-item>
<a-form-item label="Link reglamento" name="link_reglamento">
<a-input v-model:value="formState.link_reglamento" placeholder="https://..." size="large" />
</a-form-item>
</div>
<div class="form-footer" v-if="store.error">
<a-alert type="error" :message="store.error" show-icon class="error-alert" />
</div>
</a-form>
</a-modal>
<!-- Modal delete -->
<a-modal
v-model:open="deleteModalVisible"
title="Confirmar Eliminación"
@ok="handleDelete"
@cancel="deleteModalVisible = false"
ok-text="Eliminar"
cancel-text="Cancelar"
ok-type="danger"
width="420px"
>
<div class="delete-confirm-content">
<a-alert
message="¿Eliminar este proceso?"
description="Se eliminarán también todos los detalles asociados."
type="warning"
show-icon
/>
<div class="curso-info" v-if="toDelete">
<p><strong>Título:</strong> {{ toDelete.titulo }}</p>
<p><strong>Slug:</strong> {{ toDelete.slug }}</p>
<p><strong>Estado:</strong> {{ toDelete.estado }}</p>
</div>
</div>
</a-modal>
</div>
</template>
<script setup>
import { ref, reactive, onMounted, computed } from 'vue'
import { useRouter } from 'vue-router'
import { message } from 'ant-design-vue'
import {
PlusOutlined, EditOutlined, DeleteOutlined,
SearchOutlined, ReloadOutlined, SettingOutlined
} from '@ant-design/icons-vue'
import { useProcesoAdmisionStore } from '../../../store/procesosAdmisionStore'
const store = useProcesoAdmisionStore()
const router = useRouter()
const modalVisible = ref(false)
const deleteModalVisible = ref(false)
const isEditing = ref(false)
const toDelete = ref(null)
const togglingId = ref(null)
const formRef = ref()
const searchText = ref('')
const estadoFilter = ref(null)
const publicadoFilter = ref(null)
// Upload file lists
const fileImagen = ref([])
const fileBanner = ref([])
const fileBrochure = ref([])
const formState = reactive({
id: null,
titulo: '',
subtitulo: '',
descripcion: '',
slug: '',
tipo_proceso: '',
modalidad: '',
publicado: false,
fecha_publicacion: null,
fecha_inicio_preinscripcion: null,
fecha_fin_preinscripcion: null,
fecha_inicio_inscripcion: null,
fecha_fin_inscripcion: null,
fecha_examen1: null,
fecha_examen2: null,
fecha_resultados: null,
fecha_inicio_biometrico: null,
fecha_fin_biometrico: null,
link_preinscripcion: '',
link_inscripcion: '',
link_resultados: '',
link_reglamento: '',
estado: 'nuevo',
// para mostrar actuales
imagen_url: null,
banner_url: null,
brochure_url: null
})
const formRules = {
titulo: [{ required: true, message: 'Ingresa el título', trigger: 'blur' }],
slug: [{ required: true, message: 'Ingresa el slug', trigger: 'blur' }],
estado: [{ required: true, message: 'Selecciona estado', trigger: 'change' }]
}
const columns = [
{ title: 'ID', dataIndex: 'id', key: 'id', width: 70 },
{ title: 'Título', dataIndex: 'titulo', key: 'titulo' },
{ title: 'Slug', dataIndex: 'slug', key: 'slug', width: 220 },
{ title: 'Estado', dataIndex: 'estado', key: 'estado', width: 120 },
{ title: 'Publicado', key: 'publicado', width: 120 },
{ title: 'Fechas', key: 'fechas', width: 280 },
{ title: 'Archivos', key: 'imagenes', width: 220 },
{ title: 'Creado', dataIndex: 'created_at', key: 'created_at', width: 140 },
{ title: 'Acciones', key: 'acciones', width: 240, align: 'center' }
]
const pagination = computed(() => ({
current: store.pagination.current_page,
pageSize: store.pagination.per_page,
total: store.pagination.total,
showSizeChanger: true,
pageSizeOptions: ['10', '15', '20', '50'],
showTotal: (total, range) => `${range[0]}-${range[1]} de ${total} procesos`
}))
function resetUploads() {
fileImagen.value = []
fileBanner.value = []
fileBrochure.value = []
}
function resetForm() {
formState.id = null
formState.titulo = ''
formState.subtitulo = ''
formState.descripcion = ''
formState.slug = ''
formState.tipo_proceso = ''
formState.modalidad = ''
formState.publicado = false
formState.fecha_publicacion = null
formState.fecha_inicio_preinscripcion = null
formState.fecha_fin_preinscripcion = null
formState.fecha_inicio_inscripcion = null
formState.fecha_fin_inscripcion = null
formState.fecha_examen1 = null
formState.fecha_examen2 = null
formState.fecha_resultados = null
formState.fecha_inicio_biometrico = null
formState.fecha_fin_biometrico = null
formState.link_preinscripcion = ''
formState.link_inscripcion = ''
formState.link_resultados = ''
formState.link_reglamento = ''
formState.estado = 'nuevo'
formState.imagen_url = null
formState.banner_url = null
formState.brochure_url = null
resetUploads()
formRef.value?.resetFields?.()
}
function showCreateModal() {
isEditing.value = false
resetForm()
modalVisible.value = true
}
function showEditModal(row) {
isEditing.value = true
resetForm()
Object.assign(formState, {
id: row.id,
titulo: row.titulo,
subtitulo: row.subtitulo,
descripcion: row.descripcion,
slug: row.slug,
tipo_proceso: row.tipo_proceso,
modalidad: row.modalidad,
publicado: !!row.publicado,
fecha_publicacion: row.fecha_publicacion,
fecha_inicio_preinscripcion: row.fecha_inicio_preinscripcion,
fecha_fin_preinscripcion: row.fecha_fin_preinscripcion,
fecha_inicio_inscripcion: row.fecha_inicio_inscripcion,
fecha_fin_inscripcion: row.fecha_fin_inscripcion,
fecha_examen1: row.fecha_examen1,
fecha_examen2: row.fecha_examen2,
fecha_resultados: row.fecha_resultados,
fecha_inicio_biometrico: row.fecha_inicio_biometrico,
fecha_fin_biometrico: row.fecha_fin_biometrico,
link_preinscripcion: row.link_preinscripcion,
link_inscripcion: row.link_inscripcion,
link_resultados: row.link_resultados,
link_reglamento: row.link_reglamento,
estado: row.estado,
imagen_url: row.imagen_url,
banner_url: row.banner_url,
brochure_url: row.brochure_url
})
modalVisible.value = true
}
async function handleModalOk() {
try {
await formRef.value.validateFields()
await onFormSubmit()
} catch (_) {}
}
function handleModalCancel() {
modalVisible.value = false
resetForm()
}
function buildFormData() {
const fd = new FormData()
const fields = {
titulo: formState.titulo,
subtitulo: formState.subtitulo || null,
descripcion: formState.descripcion || null,
slug: formState.slug,
tipo_proceso: formState.tipo_proceso || null,
modalidad: formState.modalidad || null,
publicado: formState.publicado ? 1 : 0,
fecha_publicacion: formState.fecha_publicacion || null,
fecha_inicio_preinscripcion: formState.fecha_inicio_preinscripcion || null,
fecha_fin_preinscripcion: formState.fecha_fin_preinscripcion || null,
fecha_inicio_inscripcion: formState.fecha_inicio_inscripcion || null,
fecha_fin_inscripcion: formState.fecha_fin_inscripcion || null,
fecha_examen1: formState.fecha_examen1 || null,
fecha_examen2: formState.fecha_examen2 || null,
fecha_resultados: formState.fecha_resultados || null,
fecha_inicio_biometrico: formState.fecha_inicio_biometrico || null,
fecha_fin_biometrico: formState.fecha_fin_biometrico || null,
link_preinscripcion: formState.link_preinscripcion || null,
link_inscripcion: formState.link_inscripcion || null,
link_resultados: formState.link_resultados || null,
link_reglamento: formState.link_reglamento || null,
estado: formState.estado
}
Object.entries(fields).forEach(([k, v]) => {
if (v === null || v === undefined || v === '') return
fd.append(k, v)
})
const img = fileImagen.value?.[0]?.originFileObj
const ban = fileBanner.value?.[0]?.originFileObj
const bro = fileBrochure.value?.[0]?.originFileObj
if (img) fd.append('imagen', img)
if (ban) fd.append('banner', ban)
if (bro) fd.append('brochure', bro)
return fd
}
async function onFormSubmit() {
const fd = buildFormData()
let ok = false
if (isEditing.value) {
// multipart + PATCH -> method spoof
fd.append('_method', 'PATCH')
ok = await store.updateProceso(formState.id, fd, { method: 'post' })
} else {
ok = await store.createProceso(fd)
}
if (ok) {
message.success(isEditing.value ? 'Proceso actualizado' : 'Proceso creado')
modalVisible.value = false
resetForm()
await fetchList({ page: 1 })
} else {
message.error('Error al guardar')
}
}
function confirmDelete(row) {
toDelete.value = row
deleteModalVisible.value = true
}
async function handleDelete() {
const ok = await store.deleteProceso(toDelete.value.id)
if (ok) message.success('Eliminado')
else message.error('Error al eliminar')
deleteModalVisible.value = false
toDelete.value = null
await fetchList({ page: 1 })
}
async function togglePublicado(row) {
togglingId.value = row.id
try {
const fd = new FormData()
fd.append('publicado', row.publicado ? 0 : 1)
fd.append('_method', 'PATCH')
const ok = await store.updateProceso(row.id, fd, { method: 'post' })
if (ok) message.success('Publicado actualizado')
} finally {
togglingId.value = null
}
}
function goDetalles(row) {
router.push({ name: 'ProcesoAdmisionDetalles', params: { id: row.id } })
}
function handleSearch() { fetchList({ page: 1 }) }
function handleFiltersChange() { fetchList({ page: 1 }) }
function handleClearFilters() {
searchText.value = ''
estadoFilter.value = null
publicadoFilter.value = null
fetchList({ page: 1 })
}
function handleTableChange(paginationConfig) {
fetchList({ page: paginationConfig.current, per_page: paginationConfig.pageSize })
}
async function fetchList({ page = 1, per_page } = {}) {
await store.fetchProcesos({
q: searchText.value || undefined,
estado: estadoFilter.value ?? undefined,
publicado: publicadoFilter.value ?? undefined,
page,
per_page
})
}
function formatDate(s) {
if (!s) return ''
const d = new Date(s)
return d.toLocaleDateString('es-ES', { day: '2-digit', month: '2-digit', year: 'numeric' })
}
function shortDate(s) {
if (!s) return '-'
const d = new Date(s)
return d.toLocaleDateString('es-ES', { day: '2-digit', month: '2-digit', year: '2-digit' })
}
onMounted(() => { fetchList() })
</script>
<style scoped>
.cursos-container { padding: 0; }
.cursos-header { display:flex; justify-content:space-between; align-items:center; margin-bottom:24px; padding:0 8px; gap: 12px; flex-wrap: wrap; }
.header-title h2 { margin:0; font-size:24px; font-weight:600; color:#1f1f1f; }
.subtitle { margin:4px 0 0; color:#666; font-size:14px; }
.new-curso-btn { height:40px; font-weight:500; }
.filters-section { display:flex; align-items:center; margin-bottom:24px; padding:0 8px; flex-wrap:wrap; gap:16px; }
.loading-state { display:flex; flex-direction:column; align-items:center; justify-content:center; min-height:300px; gap:16px; }
.empty-state { min-height:300px; display:flex; align-items:center; justify-content:center; }
.cursos-table-container { background:white; border-radius:12px; box-shadow:0 2px 8px rgba(0,0,0,.06); border:1px solid #f0f0f0; overflow:hidden; }
.cursos-table { border-radius:12px; }
.grid2 { display:grid; grid-template-columns: 1fr 1fr; gap: 14px; }
.grid3 { display:grid; grid-template-columns: 1fr 1fr 1fr; gap: 14px; }
.hint { display:block; margin-top: 6px; color:#666; }
.mini-dates { line-height: 1.35; font-size: 12px; color: #444; }
@media (max-width: 768px) { .grid2, .grid3 { grid-template-columns: 1fr; } }
</style>
Loading…
Cancel
Save