123456
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']);
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
@ -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">Sí</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…
Reference in New Issue