feat: agregado subir archvios txt de resultados

main
parent 46545d44a8
commit b1f36301bd

@ -243,8 +243,16 @@ CREATE TABLE IF NOT EXISTS `migrations` (
PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=6 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- Volcando datos para la tabla admision_2026.migrations: ~0 rows (aproximadamente)
-- Volcando datos para la tabla admision_2026.migrations: ~7 rows (aproximadamente)
DELETE FROM `migrations`;
INSERT INTO `migrations` (`migration`, `batch`) VALUES
('0001_01_01_000000_create_users_table', 1),
('0001_01_01_000001_create_cache_table', 1),
('0001_01_01_000002_create_jobs_table', 1),
('2026_01_27_132900_create_personal_access_tokens_table', 1),
('2026_01_27_133609_create_permission_tables', 1),
('2026_02_15_051618_fix_unique_constraint_proceso_admision_detalles', 1),
('2026_02_20_000001_create_proceso_admision_resultado_archivos_table', 2);
-- Volcando estructura para tabla admision_2026.model_has_permissions
CREATE TABLE IF NOT EXISTS `model_has_permissions` (
@ -566,6 +574,23 @@ CREATE TABLE IF NOT EXISTS `proceso_admision_detalles` (
-- Volcando datos para la tabla admision_2026.proceso_admision_detalles: ~4 rows (aproximadamente)
DELETE FROM `proceso_admision_detalles`;
-- Volcando estructura para tabla admision_2026.proceso_admision_resultado_archivos
CREATE TABLE IF NOT EXISTS `proceso_admision_resultado_archivos` (
`id` bigint unsigned NOT NULL AUTO_INCREMENT,
`proceso_admision_id` bigint unsigned NOT NULL,
`nombre` varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL,
`file_path` varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL,
`orden` tinyint unsigned NOT NULL,
`created_at` timestamp NULL DEFAULT NULL,
`updated_at` timestamp NULL DEFAULT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `uniq_proceso_orden` (`proceso_admision_id`,`orden`),
CONSTRAINT `proceso_admision_resultado_archivos_proceso_admision_id_foreign` FOREIGN KEY (`proceso_admision_id`) REFERENCES `procesos_admision` (`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- Volcando datos para la tabla admision_2026.proceso_admision_resultado_archivos: ~0 rows (aproximadamente)
DELETE FROM `proceso_admision_resultado_archivos`;
-- Volcando estructura para tabla admision_2026.reglas_area_proceso
CREATE TABLE IF NOT EXISTS `reglas_area_proceso` (
`id` bigint unsigned NOT NULL AUTO_INCREMENT,

@ -156,6 +156,13 @@ class ProcesoAdmisionController extends Controller
$data['slug'] = Str::slug($data['titulo'] ?? $proceso->titulo);
}
// FormData envía strings vacíos; los convertimos a null para limpiar el campo en DB
foreach (['link_preinscripcion', 'link_inscripcion', 'link_resultados', 'link_reglamento'] as $key) {
if (array_key_exists($key, $data) && $data[$key] === '') {
$data[$key] = null;
}
}
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');

@ -0,0 +1,71 @@
<?php
namespace App\Http\Controllers\Administracion;
use App\Http\Controllers\Controller;
use App\Models\ProcesoAdmisionResultadoArchivo;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Storage;
class ProcesoAdmisionResultadoArchivoController extends Controller
{
public function index(int $procesoId)
{
$archivos = ProcesoAdmisionResultadoArchivo::where('proceso_admision_id', $procesoId)
->orderBy('orden')
->get();
return response()->json($archivos);
}
public function store(Request $request, int $procesoId)
{
$request->validate([
'orden' => 'required|integer|min:1|max:6',
'nombre' => 'required|string|max:255',
'archivo' => 'required|file|mimes:txt|max:10240',
]);
// Máximo 6 archivos por proceso
$count = ProcesoAdmisionResultadoArchivo::where('proceso_admision_id', $procesoId)->count();
if ($count >= 6) {
return response()->json(['message' => 'Máximo 6 archivos por proceso.'], 422);
}
// No puede haber dos archivos con el mismo orden en el mismo proceso
$existe = ProcesoAdmisionResultadoArchivo::where('proceso_admision_id', $procesoId)
->where('orden', $request->orden)
->exists();
if ($existe) {
return response()->json(['message' => 'Ya existe un archivo para ese slot.'], 422);
}
$contenido = file_get_contents($request->file('archivo')->getRealPath());
$encoding = mb_detect_encoding($contenido, ['UTF-8', 'Windows-1252', 'ISO-8859-1'], true);
$contenidoUtf8 = ($encoding === 'UTF-8')
? $contenido
: mb_convert_encoding($contenido, 'UTF-8', $encoding ?: 'Windows-1252');
$filename = uniqid() . '.txt';
$path = "proceso-resultados/{$procesoId}/{$filename}";
\Storage::disk('public')->put($path, $contenidoUtf8);
$archivo = ProcesoAdmisionResultadoArchivo::create([
'proceso_admision_id' => $procesoId,
'nombre' => $request->nombre,
'file_path' => $path,
'orden' => $request->orden,
]);
return response()->json($archivo, 201);
}
public function destroy(int $id)
{
$archivo = ProcesoAdmisionResultadoArchivo::findOrFail($id);
Storage::disk('public')->delete($archivo->file_path);
$archivo->delete();
return response()->json(['message' => 'Eliminado correctamente.']);
}
}

@ -58,6 +58,7 @@ class WebController extends Controller
);
}
])
->where('publicado', 1)
->latest()
->get();

@ -66,6 +66,12 @@ class ProcesoAdmision extends Model
return $this->hasMany(ResultadoAdmision::class, 'idproceso');
}
public function resultadoArchivos()
{
return $this->hasMany(ProcesoAdmisionResultadoArchivo::class, 'proceso_admision_id')
->orderBy('orden');
}
}

@ -0,0 +1,36 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\Storage;
class ProcesoAdmisionResultadoArchivo extends Model
{
protected $table = 'proceso_admision_resultado_archivos';
protected $fillable = [
'proceso_admision_id',
'nombre',
'file_path',
'orden',
];
protected $casts = [
'orden' => 'integer',
];
protected $appends = ['archivo_url'];
public function getArchivoUrlAttribute(): ?string
{
return $this->file_path
? Storage::disk('public')->url($this->file_path)
: null;
}
public function proceso(): \Illuminate\Database\Eloquent\Relations\BelongsTo
{
return $this->belongsTo(ProcesoAdmision::class, 'proceso_admision_id');
}
}

@ -0,0 +1,29 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('proceso_admision_resultado_archivos', function (Blueprint $table) {
$table->id();
$table->foreignId('proceso_admision_id')
->constrained('procesos_admision')
->onDelete('cascade');
$table->string('nombre');
$table->string('file_path');
$table->unsignedTinyInteger('orden');
$table->timestamps();
$table->unique(['proceso_admision_id', 'orden'], 'uniq_proceso_orden');
});
}
public function down(): void
{
Schema::dropIfExists('proceso_admision_resultado_archivos');
}
};

@ -18,6 +18,7 @@ use App\Http\Controllers\Administracion\ProcesoAdmisionDetalleController;
use App\Http\Controllers\Administracion\PostulanteController;
use App\Http\Controllers\Administracion\CalificacionController;
use App\Http\Controllers\Administracion\NoticiaController;
use App\Http\Controllers\Administracion\ProcesoAdmisionResultadoArchivoController;
use App\Http\Controllers\WebController;
Route::get('/user', function (Request $request) {
@ -196,4 +197,14 @@ Route::middleware('auth:sanctum')->prefix('admin')->group(function () {
Route::get('/procesos-admision', [WebController::class, 'GetProcesoAdmision']);
Route::get('/noticias', [NoticiaController::class, 'index']);
Route::get('/noticias/{noticia:slug}', [NoticiaController::class, 'showPublic']);
Route::get('/noticias/{noticia:slug}', [NoticiaController::class, 'showPublic']);
// Ruta pública: archivos de resultado del proceso vigente
Route::get('/proceso-resultado/{procesoId}/archivos', [ProcesoAdmisionResultadoArchivoController::class, 'index']);
// Rutas admin: gestión de archivos de resultado
Route::middleware('auth:sanctum')->prefix('admin')->group(function () {
Route::get('/proceso-resultado/{procesoId}/archivos', [ProcesoAdmisionResultadoArchivoController::class, 'index']);
Route::post('/proceso-resultado/{procesoId}/archivos', [ProcesoAdmisionResultadoArchivoController::class, 'store']);
Route::delete('/proceso-resultado/archivos/{id}', [ProcesoAdmisionResultadoArchivoController::class, 'destroy']);
});

@ -102,13 +102,30 @@
<a-divider class="custom-divider" />
<div class="preinscripcion-section">
<!-- Resultados (cuando hay archivos subidos) -->
<div v-if="hayResultados" class="preinscripcion-section">
<div class="preinscripcion-info">
<h4 class="subheading">Resultados del Examen</h4>
<p>Consulta los resultados del proceso de admisión vigente</p>
</div>
<div class="preinscripcion-btn-wrap">
<a-button
type="primary"
size="large"
@click="router.push('/proceso-resultado')"
>
<template #icon><BarChartOutlined /></template>
Ver Resultados
</a-button>
</div>
</div>
<!-- Preinscripción (cuando no hay resultados) -->
<div v-else class="preinscripcion-section">
<div class="preinscripcion-info">
<h4 class="subheading">Preinscripción en Línea</h4>
<p>Completa tu preinscripción de manera virtual y segura</p>
</div>
<div v-if="store.procesoPrincipal?.link_preinscripcion" class="preinscripcion-btn-wrap">
<a-button
type="primary"
@ -231,8 +248,10 @@
</template>
<script setup>
import { onMounted, ref } from "vue"
import { onMounted, ref, computed } from "vue"
import { useWebAdmisionStore } from "../../store/web"
import { useProcesoAdmisionResultadoStore } from "../../store/procesoAdmisionResultado.store"
import { useRouter } from "vue-router"
import {
FileTextOutlined,
@ -240,9 +259,14 @@ import {
TeamOutlined,
CalendarOutlined,
FormOutlined,
BarChartOutlined,
} from "@ant-design/icons-vue"
const store = useWebAdmisionStore()
const resultadosStore = useProcesoAdmisionResultadoStore()
const router = useRouter()
const hayResultados = computed(() => resultadosStore.archivos.length > 0)
const emit = defineEmits(["show-modal", "open-preinscripcion"])

@ -45,48 +45,90 @@
</div>
</div>
<!-- CARD -->
<!-- <div class="hero-visual">
<!-- CARD: solo visible cuando hay archivos de resultado publicados -->
<div v-if="hayResultados" class="hero-visual">
<div class="glass-card">
<div class="card-header">
<CalendarOutlined />
<span>Próximo evento</span>
<span>Resultados del:</span>
</div>
<h3>Charla informativa</h3>
<h3>{{ procesoPrincipal?.titulo ?? 'Proceso vigente' }}</h3>
<p class="card-date">
<ClockCircleOutlined />
25 Nov 4:00 PM Virtual
{{ fechasExamen }}
</p>
<a-button
size="large"
class="secondary-button"
@click="$emit('virtual-tour')"
@click="irAResultados"
>
<template #icon><PlayCircleOutlined /></template>
Tour virtual
<template #icon><BarChartOutlined /></template>
Resultados del Examen
</a-button>
</div>
</div> -->
</div>
</div>
</section>
</template>
<script setup>
const heroImg = "/PORTADA.jpg.jpeg";
import { computed, onMounted } from "vue"
import { useRouter } from "vue-router"
import {
RightCircleOutlined,
PlayCircleOutlined,
CalendarOutlined,
CheckCircleOutlined,
ClockCircleOutlined
} from "@ant-design/icons-vue";
ClockCircleOutlined,
BarChartOutlined
} from "@ant-design/icons-vue"
import { useWebAdmisionStore } from "../../store/web"
import { useProcesoAdmisionResultadoStore } from "../../store/procesoAdmisionResultado.store"
const heroImg = "/PORTADA.jpg.jpeg"
const router = useRouter()
const webStore = useWebAdmisionStore()
const resultadosStore = useProcesoAdmisionResultadoStore()
const procesoPrincipal = computed(() => webStore.procesoPrincipal)
const hayResultados = computed(() => resultadosStore.archivos.length > 0)
const fechasExamen = computed(() => {
const p = procesoPrincipal.value
if (!p) return ''
const fmt = (iso) => {
if (!iso) return null
const d = new Date(iso)
if (Number.isNaN(d.getTime())) return null
return d.toLocaleDateString('es-PE', { day: '2-digit', month: 'long' })
}
const f1 = fmt(p.fecha_examen1)
const f2 = fmt(p.fecha_examen2)
if (f1 && f2) return `${f1}${f2}`
return f1 ?? f2 ?? ''
})
function irAResultados() {
router.push('/proceso-resultado')
}
onMounted(async () => {
if (!webStore.procesos.length) {
await webStore.cargarProcesos()
}
const proceso = procesoPrincipal.value
if (proceso?.id) {
await resultadosStore.fetchArchivosPublico(proceso.id)
}
})
defineEmits(["scroll-to-convocatoria", "virtual-tour"]);
defineEmits(["scroll-to-convocatoria", "virtual-tour"])
</script>
<style scoped>
@ -196,7 +238,7 @@ defineEmits(["scroll-to-convocatoria", "virtual-tour"]);
}
.glass-card {
background: rgba(106, 136, 219, 0.55);
background: rgba(17, 59, 173, 0.754);
padding: 28px;
border-radius: 16px;
width: 100%;
@ -204,7 +246,7 @@ defineEmits(["scroll-to-convocatoria", "virtual-tour"]);
box-shadow: 0 25px 60px rgba(0, 0, 0, 0.3);
}
.glass-cardtitle {
background: rgba(190, 200, 228, 0.55);
background: rgba(72, 93, 151, 0.55);
padding: 12px 24px;
border-radius: 16px;
width: fit-content;

@ -0,0 +1,312 @@
<script setup>
import { onMounted } from "vue"
import NavbarModerno from '../../nabvar.vue'
import FooterModerno from '../../Footer.vue'
import { FileSearchOutlined } from "@ant-design/icons-vue"
import { useWebAdmisionStore } from '../../../store/web'
import { useProcesoAdmisionResultadoStore } from '../../../store/procesoAdmisionResultado.store'
const webStore = useWebAdmisionStore()
const resultadosStore = useProcesoAdmisionResultadoStore()
onMounted(async () => {
if (!webStore.procesos.length) {
await webStore.cargarProcesos()
}
const proceso = webStore.procesoPrincipal
if (proceso?.id) {
await resultadosStore.fetchArchivosPublico(proceso.id)
}
})
</script>
<template>
<NavbarModerno />
<section id="proceso-resultado" class="convocatorias-modern">
<div class="section-container">
<div class="section-header">
<div class="header-with-badge">
<h2 class="section-title">
RESULTADOS {{ webStore.procesoPrincipal?.titulo ?? 'Proceso vigente' }}
</h2>
</div>
<p class="section-subtitle">
Resultados del proceso de admisión vigente.
</p>
</div>
<!-- Estado -->
<a-card class="main-convocatoria-card" :loading="resultadosStore.loading">
<div class="card-badge">Resultados</div>
<template v-if="resultadosStore.error">
<div style="padding: 6px 2px; color: #dc2626; font-weight: 700;">
{{ resultadosStore.error }}
</div>
</template>
<template v-else-if="!resultadosStore.archivos.length && !resultadosStore.loading">
<div style="padding: 6px 2px; color: #666;">
Resultados próximamente.
</div>
</template>
<template v-else>
<div style="padding: 6px 2px; color:#666;">
Resultados disponibles {{ webStore.procesoPrincipal?.titulo }}
</div>
</template>
</a-card>
<!-- Cards de archivos -->
<a-card
v-if="resultadosStore.archivos.length"
class="year-section-card"
>
<div class="year-header">
<div class="year-icon">
<FileSearchOutlined />
</div>
<div>
<h3 class="year-title">{{ webStore.procesoPrincipal?.titulo }}</h3>
<p class="year-subtitle">Selecciona el archivo que deseas consultar.</p>
</div>
</div>
<a-divider class="custom-divider" />
<div class="secondary-list one-col">
<a-card
v-for="archivo in resultadosStore.archivos"
:key="archivo.id"
class="secondary-convocatoria-card"
>
<div class="convocatoria-header">
<div>
<h4 class="secondary-title">{{ archivo.nombre }}</h4>
</div>
<a-tag class="status-tag" color="blue">DISPONIBLE</a-tag>
</div>
<div class="card-footer">
<a-button
type="primary"
ghost
size="small"
:href="archivo.archivo_url"
target="_blank"
>
<template #icon><FileSearchOutlined /></template>
Ver Resultados
</a-button>
</div>
</a-card>
</div>
</a-card>
</div>
</section>
<FooterModerno />
</template>
<style scoped>
.convocatorias-modern {
position: relative;
padding: 40px 0;
font-family: "Times New Roman", Times, serif;
background: #fbfcff;
overflow: hidden;
}
.convocatorias-modern::before {
content: "";
position: absolute;
inset: 0;
pointer-events: none;
z-index: 0;
background-image:
repeating-linear-gradient(
to right,
rgba(13, 27, 82, 0.06) 0,
rgba(13, 27, 82, 0.06) 1px,
transparent 1px,
transparent 24px
),
repeating-linear-gradient(
to bottom,
rgba(13, 27, 82, 0.06) 0,
rgba(13, 27, 82, 0.06) 1px,
transparent 1px,
transparent 24px
);
opacity: 0.55;
}
.section-container {
position: relative;
z-index: 1;
max-width: 1200px;
margin: 0 auto;
padding: 0 24px;
}
.section-header {
text-align: center;
margin-bottom: 50px;
}
.header-with-badge {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 14px;
}
.section-title {
font-size: 2.6rem;
font-weight: 700;
color: #0d1b52;
margin: 0;
}
.section-subtitle {
font-size: 1.125rem;
color: #666;
max-width: 640px;
margin: 14px auto 0;
}
.main-convocatoria-card {
position: relative;
border: none;
box-shadow: 0 10px 34px rgba(0, 0, 0, 0.08);
border-radius: 16px;
margin-bottom: 18px;
}
.main-convocatoria-card :deep(.ant-card-body) {
padding: 28px;
}
.card-badge {
position: absolute;
top: -12px;
left: 24px;
background: linear-gradient(45deg, #1890ff, #52c41a);
color: white;
padding: 6px 16px;
border-radius: 999px;
font-size: 0.75rem;
font-weight: 700;
}
.year-section-card {
border: none;
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.07);
border-radius: 16px;
margin-top: 18px;
}
.year-section-card :deep(.ant-card-body) {
padding: 22px;
}
.year-header {
display: flex;
align-items: center;
gap: 14px;
}
.year-icon {
width: 44px;
height: 44px;
border-radius: 999px;
display: grid;
place-items: center;
background: rgba(24, 144, 255, 0.12);
color: #1890ff;
font-size: 18px;
}
.year-title {
margin: 0;
font-size: 1.35rem;
color: #1a237e;
font-weight: 700;
font-family: "Times New Roman", Times, serif;
}
.year-subtitle {
margin: 4px 0 0;
color: #666;
font-size: 0.95rem;
}
.custom-divider {
margin: 18px 0;
}
.secondary-convocatoria-card {
border: none;
box-shadow: 0 6px 18px rgba(0, 0, 0, 0.08);
border-radius: 12px;
}
.secondary-convocatoria-card :deep(.ant-card-body) {
padding: 18px;
display: flex;
flex-direction: column;
justify-content: center;
min-height: 100px;
}
.convocatoria-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 14px;
margin-bottom: 14px;
}
.secondary-title {
margin: 0;
font-size: 1.05rem;
color: #1a237e;
text-align: center;
text-transform: uppercase;
font-family: "Times New Roman", Times, serif;
}
.status-tag {
font-weight: 700;
padding: 4px 12px;
border-radius: 999px;
white-space: nowrap;
}
.card-footer {
display: flex;
align-items: center;
margin-top: 12px;
}
.card-footer > :last-child {
margin-left: auto;
}
.secondary-list.one-col {
display: grid;
grid-template-columns: 1fr;
gap: 16px;
}
@media (max-width: 992px) {
.section-title { font-size: 2.1rem; }
}
@media (max-width: 768px) {
.convocatorias-modern { padding: 55px 0; }
}
</style>

@ -475,9 +475,6 @@ onUnmounted(() => {
.modern-header {
padding: 0 12px !important;
}
.logo-text span {
display: none;
}
.mobile-menu-btn {
font-size: 20px;
padding: 6px;

@ -29,6 +29,11 @@ const routes = [
path: '/resultados',
name: 'Resultados',
component: () => import('../components/WebPageSections/navbarcontent/Resultados.vue')
},
{
path: '/proceso-resultado',
name: 'ProcesoResultado',
component: () => import('../components/WebPageSections/navbarcontent/ProcesoResultado.vue')
},
{
path: '/modalidades/cepreuna',

@ -0,0 +1,113 @@
import { defineStore } from 'pinia'
import api from '../axios'
import apiPublico from '../axiosPostulante'
// Genera el nombre predeterminado para cada slot según las fechas del proceso.
// El admin puede editarlo antes de subir, pero esto es el punto de partida.
export function generarNombreSlot(orden, proceso) {
const titulo = proceso?.titulo ?? 'Examen'
const formatDia = (isoStr, abrev = false) => {
if (!isoStr) return ''
const d = new Date(isoStr)
if (Number.isNaN(d.getTime())) return ''
const dias = ['domingo', 'lunes', 'martes', 'miércoles', 'jueves', 'viernes', 'sábado']
const diasAbrev = ['Dom.', 'Lun.', 'Mar.', 'Mié.', 'Jue.', 'Vie.', 'Sáb.']
const meses = ['enero','febrero','marzo','abril','mayo','junio','julio','agosto','septiembre','octubre','noviembre','diciembre']
const nombreDia = abrev ? diasAbrev[d.getDay()] : dias[d.getDay()]
const dia = String(d.getDate()).padStart(2, '0')
const mes = meses[d.getMonth()]
const anio = d.getFullYear()
return abrev
? `${nombreDia} ${d.getDate()} ${mes.charAt(0).toUpperCase() + mes.slice(1)}`
: `${nombreDia} ${dia} de ${mes}`
}
const f1 = formatDia(proceso?.fecha_examen1)
const f2 = formatDia(proceso?.fecha_examen2)
const f2abrev = formatDia(proceso?.fecha_examen2, true)
const anio2 = proceso?.fecha_examen2 ? new Date(proceso.fecha_examen2).getFullYear() : ''
const plantillas = {
1: `Ingresantes ${titulo} ${f1}`,
2: `Ingresantes ${titulo} ${f1} (CONADIS)`,
3: `Clasificados para el examen del ${f2} del ${anio2}`,
4: `Clasificados para la 2da etapa del ${f2} (CONADIS)`,
5: `Ingresantes ${titulo} ${f2}`,
6: `Ingresantes ${titulo} ${f2} (CONADIS)`,
}
return plantillas[orden] ?? `Resultado ${orden}`
}
export const useProcesoAdmisionResultadoStore = defineStore('procesoAdmisionResultado', {
state: () => ({
archivos: [],
loading: false,
error: null,
}),
actions: {
// Uso público: HeroSection + ProcesoResultado.vue
async fetchArchivosPublico(procesoId) {
this.loading = true
this.error = null
try {
const res = await apiPublico.get(`/proceso-resultado/${procesoId}/archivos`)
this.archivos = res.data ?? []
} catch (err) {
this.error = err.response?.data?.message ?? 'Error al cargar archivos'
this.archivos = []
} finally {
this.loading = false
}
},
// Uso admin: modal en ProcesosAdmisionList
async fetchArchivosAdmin(procesoId) {
this.loading = true
this.error = null
try {
const res = await api.get(`/admin/proceso-resultado/${procesoId}/archivos`)
this.archivos = res.data ?? []
} catch (err) {
this.error = err.response?.data?.message ?? 'Error al cargar archivos'
this.archivos = []
} finally {
this.loading = false
}
},
async subirArchivo(procesoId, formData) {
this.error = null
try {
const res = await api.post(
`/admin/proceso-resultado/${procesoId}/archivos`,
formData,
{ headers: { 'Content-Type': 'multipart/form-data' } }
)
this.archivos = [...this.archivos, res.data].sort((a, b) => a.orden - b.orden)
return true
} catch (err) {
this.error = err.response?.data?.message ?? 'Error al subir archivo'
return false
}
},
async eliminarArchivo(id) {
try {
await api.delete(`/admin/proceso-resultado/archivos/${id}`)
this.archivos = this.archivos.filter((a) => a.id !== id)
return true
} catch (err) {
this.error = err.response?.data?.message ?? 'Error al eliminar'
return false
}
},
limpiar() {
this.archivos = []
this.error = null
},
},
})

@ -12,8 +12,7 @@ export const useWebAdmisionStore = defineStore("procesoAdmision", {
getters: {
// Si hay uno VIGENTE, úsalo como principal; si no, usa el primero.
procesoPrincipal: (state) => {
if (!state.procesos?.length) return null
return state.procesos.find((p) => p.estado === "publicado") ?? state.procesos[0]
return state.procesos?.[0] ?? null
},
// Por si lo necesitas después

@ -128,6 +128,9 @@
<!-- Acciones -->
<template v-else-if="column.key === 'acciones'">
<a-space>
<a-button type="link" size="small" @click="showResultadosModal(record)">
<FileTextOutlined /> Resultados
</a-button>
<a-button type="link" size="small" @click="goDetalles(record)">
<SettingOutlined /> Detalles
</a-button>
@ -391,6 +394,70 @@
</a-form>
</a-modal>
<!-- Modal Resultados -->
<a-modal
v-model:open="resultadosModalVisible"
:title="`Archivos de Resultado — ${procesoResultadoActual?.titulo ?? ''}`"
width="780px"
@ok="cerrarResultadosModal"
@cancel="cerrarResultadosModal"
>
<a-spin :spinning="resultadosStore.loading">
<div class="slots-grid">
<div
v-for="slot in 6"
:key="slot"
class="slot-row"
>
<div class="slot-info">
<span class="slot-num">{{ slot }}</span>
<span class="slot-nombre">{{ generarNombreSlot(slot, procesoResultadoActual) }}</span>
</div>
<div class="slot-actions">
<!-- Si ya tiene archivo -->
<template v-if="archivoDelSlot(slot)">
<a :href="archivoDelSlot(slot).archivo_url" target="_blank" class="slot-link">
<FileTextOutlined /> Ver archivo
</a>
<a-popconfirm
title="¿Eliminar este archivo?"
ok-text="Eliminar"
cancel-text="Cancelar"
@confirm="eliminarArchivo(archivoDelSlot(slot).id)"
>
<a-button type="link" danger size="small">
<DeleteOutlined />
</a-button>
</a-popconfirm>
</template>
<!-- Si no tiene archivo -->
<template v-else>
<a-upload
:customRequest="({ file, onSuccess, onError }) => subirArchivo(slot, file, onSuccess, onError)"
:show-upload-list="false"
accept=".txt"
>
<a-button size="small" :loading="uploadingSlot === slot">
<UploadOutlined /> Subir .txt
</a-button>
</a-upload>
</template>
</div>
</div>
</div>
<a-alert
v-if="resultadosStore.error"
:message="resultadosStore.error"
type="error"
show-icon
class="mt-3"
/>
</a-spin>
</a-modal>
<!-- Modal delete -->
<a-modal
v-model:open="deleteModalVisible"
@ -425,15 +492,21 @@ import { useRouter } from 'vue-router'
import { message } from 'ant-design-vue'
import {
PlusOutlined, EditOutlined, DeleteOutlined,
SearchOutlined, ReloadOutlined, SettingOutlined
SearchOutlined, ReloadOutlined, SettingOutlined,
FileTextOutlined, UploadOutlined
} from '@ant-design/icons-vue'
import { useProcesoAdmisionStore } from '../../../store/procesosAdmisionStore'
import { useProcesoAdmisionResultadoStore, generarNombreSlot } from '../../../store/procesoAdmisionResultado.store'
const store = useProcesoAdmisionStore()
const resultadosStore = useProcesoAdmisionResultadoStore()
const router = useRouter()
const modalVisible = ref(false)
const deleteModalVisible = ref(false)
const resultadosModalVisible = ref(false)
const procesoResultadoActual = ref(null)
const uploadingSlot = ref(null)
const isEditing = ref(false)
const toDelete = ref(null)
const togglingId = ref(null)
@ -630,11 +703,6 @@ function buildFormData() {
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
}
@ -643,6 +711,10 @@ function buildFormData() {
fd.append(k, v)
})
// Links: siempre se envían (incluso vacíos) para permitir borrarlos
const links = ['link_preinscripcion', 'link_inscripcion', 'link_resultados', 'link_reglamento']
links.forEach(k => fd.append(k, formState[k] ?? ''))
const img = fileImagen.value?.[0]?.originFileObj
const ban = fileBanner.value?.[0]?.originFileObj
const bro = fileBrochure.value?.[0]?.originFileObj
@ -706,6 +778,49 @@ function goDetalles(row) {
router.push({ name: 'ProcesoAdmisionDetalles', params: { id: row.id } })
}
// Resultados modal
function archivoDelSlot(orden) {
return resultadosStore.archivos.find((a) => a.orden === orden) ?? null
}
async function showResultadosModal(row) {
procesoResultadoActual.value = row
resultadosStore.limpiar()
resultadosModalVisible.value = true
await resultadosStore.fetchArchivosAdmin(row.id)
}
function cerrarResultadosModal() {
resultadosModalVisible.value = false
procesoResultadoActual.value = null
resultadosStore.limpiar()
}
async function subirArchivo(orden, file, onSuccess, onError) {
uploadingSlot.value = orden
const nombre = generarNombreSlot(orden, procesoResultadoActual.value)
const fd = new FormData()
fd.append('orden', orden)
fd.append('nombre', nombre)
fd.append('archivo', file)
const ok = await resultadosStore.subirArchivo(procesoResultadoActual.value.id, fd)
uploadingSlot.value = null
if (ok) {
onSuccess?.()
message.success('Archivo subido correctamente')
} else {
onError?.()
message.error(resultadosStore.error ?? 'Error al subir')
}
}
async function eliminarArchivo(id) {
const ok = await resultadosStore.eliminarArchivo(id)
if (ok) message.success('Archivo eliminado')
else message.error(resultadosStore.error ?? 'Error al eliminar')
}
//
function handleSearch() { fetchList({ page: 1 }) }
function handleFiltersChange() { fetchList({ page: 1 }) }
@ -761,4 +876,22 @@ onMounted(() => { fetchList() })
.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; } }
/* ── Modal Resultados ── */
.slots-grid { display: flex; flex-direction: column; gap: 16px; }
.slot-row {
display: flex; align-items: center; justify-content: space-between;
gap: 12px; padding: 14px 18px; border-radius: 8px;
background: #f7f8fc; border: 1px solid #e8eaf0;
}
.slot-info { display: flex; align-items: center; gap: 10px; flex: 1; min-width: 0; }
.slot-num {
min-width: 24px; height: 24px; border-radius: 50%;
background: #1890ff; color: #fff; font-size: 12px; font-weight: 700;
display: grid; place-items: center; flex-shrink: 0;
}
.slot-nombre { font-size: 13px; color: #333; line-height: 1.4; }
.slot-actions { display: flex; align-items: center; gap: 8px; flex-shrink: 0; }
.slot-link { font-size: 13px; color: #1890ff; display: flex; align-items: center; gap: 4px; }
.mt-3 { margin-top: 12px; }
</style>

@ -68,7 +68,9 @@ docker exec admision_2026_db mysql -uroot -proot admision_2026 -e "SHOW TABLES;"
Debe mostrar 34 tablas (users, postulantes, areas, cursos, examenes, procesos_admision, etc.)
> **Nota:** El dump ya incluye los registros en la tabla `migrations`, por lo que no es necesario ejecutar `php artisan migrate`.
> **Nota:** El dump incluye la estructura de todas las tablas **y** los registros en `migrations`.
> Aun así, siempre que hagas `git pull` debes correr `php artisan migrate` — el dump cubre las tablas
> base, pero las migraciones nuevas que se agreguen al repo después del dump no estarán en él.
---
@ -108,7 +110,37 @@ php artisan key:generate
php artisan storage:link
```
### 4.5 Ejecutar seeders
### 4.5 Correr migraciones nuevas
```bash
php artisan migrate
```
El dump cubre las tablas base. Este comando aplica cualquier migración nueva que se haya
agregado al repo después de que el dump fue generado. Si no hay migraciones pendientes,
el comando termina sin hacer nada — es seguro correrlo siempre.
**Si el comando falla** con un error tipo `Table 'users' already exists`, significa que el dump
no incluye los registros en la tabla `migrations`. Solución — correr esto primero y luego volver a migrar:
```bash
php artisan tinker --execute="
DB::table('migrations')->insertOrIgnore([
['migration' => '0001_01_01_000000_create_users_table', 'batch' => 1],
['migration' => '0001_01_01_000001_create_cache_table', 'batch' => 1],
['migration' => '0001_01_01_000002_create_jobs_table', 'batch' => 1],
['migration' => '2026_01_27_132900_create_personal_access_tokens_table', 'batch' => 1],
['migration' => '2026_01_27_133609_create_permission_tables', 'batch' => 1],
]);
echo 'done';
"
```
```bash
php artisan migrate
```
### 4.6 Ejecutar seeders
```bash
php artisan db:seed --class=RoleSeeder
@ -116,9 +148,7 @@ php artisan db:seed --class=RoleSeeder
Esto crea los roles: `usuario`, `administrador`, `superadmin`.
> **Nota:** Solo ejecutar `php artisan migrate` si en el futuro se agregan nuevas migraciones al proyecto.
### 4.6 Asignar rol a tu usuario
### 4.7 Asignar rol a tu usuario
Si ya tienes un usuario creado (por registro o por el dump SQL), asignarle un rol desde tinker:
@ -208,6 +238,54 @@ Estara disponible en: **http://localhost:5173**
## Notas importantes
- **No modificar** `composer.lock` a menos que todos los del equipo acuerden (requiere PHP 8.4+)
- El dump SQL (`admision_2006-vI.sql`) incluye la estructura de todas las tablas + registros en `migrations`. No es necesario ejecutar `php artisan migrate` a menos que se hayan agregado nuevas migraciones al proyecto.
- El dump SQL (`admision_2006-vI.sql`) es la fuente de verdad de la base de datos. Siempre se trabaja a partir de él — nunca desde `php artisan migrate:fresh` o similar.
- Cada vez que se agrega una feature con nueva migración: el desarrollador corre `php artisan migrate` en local, luego exporta solo la tabla nueva del dump y la añade al dump maestro para que el equipo pueda importarla.
- Si el puerto 3306 esta ocupado (por otro MySQL local), detener ese servicio primero o cambiar el puerto en `docker-compose.yml`
- Los archivos `.env` no se suben al repositorio (estan en `.gitignore`)
---
## Workflow cuando hay una migración nueva en el repo
Cuando haces `git pull` y hay una migración nueva (alguien agregó una feature con nueva tabla):
### Paso 1: verificar el estado
```bash
php artisan migrate:status
```
### Caso A: solo aparece 1 migración como Pending
Eso es lo normal. Solo corre:
```bash
php artisan migrate
```
Listo.
### Caso B: aparecen TODAS las migraciones como Pending
Esto pasa cuando instalaste desde el dump SQL y **nunca corriste** `php artisan migrate` antes. La tabla `migrations` está incompleta y Laravel cree que todo está pendiente — si corres `migrate` directamente fallará con `Table 'users' already exists`.
Solución: registrar las migraciones base manualmente y luego migrar.
```bash
php artisan tinker --execute="
DB::table('migrations')->insertOrIgnore([
['migration' => '0001_01_01_000000_create_users_table', 'batch' => 1],
['migration' => '0001_01_01_000001_create_cache_table', 'batch' => 1],
['migration' => '0001_01_01_000002_create_jobs_table', 'batch' => 1],
['migration' => '2026_01_27_132900_create_personal_access_tokens_table', 'batch' => 1],
['migration' => '2026_01_27_133609_create_permission_tables', 'batch' => 1],
]);
echo 'done';
"
```
```bash
php artisan migrate
```
Ahora sí solo correrá la migración nueva que falta.

Loading…
Cancel
Save