diff --git a/admision_2006-vI.sql b/admision_2006-vI.sql
index 122af8d..dba075d 100644
--- a/admision_2006-vI.sql
+++ b/admision_2006-vI.sql
@@ -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,
diff --git a/back/app/Http/Controllers/Administracion/ProcesoAdmisionController.php b/back/app/Http/Controllers/Administracion/ProcesoAdmisionController.php
index 5967f36..ccef85b 100644
--- a/back/app/Http/Controllers/Administracion/ProcesoAdmisionController.php
+++ b/back/app/Http/Controllers/Administracion/ProcesoAdmisionController.php
@@ -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');
diff --git a/back/app/Http/Controllers/Administracion/ProcesoAdmisionResultadoArchivoController.php b/back/app/Http/Controllers/Administracion/ProcesoAdmisionResultadoArchivoController.php
new file mode 100644
index 0000000..6579fba
--- /dev/null
+++ b/back/app/Http/Controllers/Administracion/ProcesoAdmisionResultadoArchivoController.php
@@ -0,0 +1,71 @@
+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.']);
+ }
+}
diff --git a/back/app/Http/Controllers/WebController.php b/back/app/Http/Controllers/WebController.php
index 9b7ca21..0f0cbe7 100644
--- a/back/app/Http/Controllers/WebController.php
+++ b/back/app/Http/Controllers/WebController.php
@@ -58,6 +58,7 @@ class WebController extends Controller
);
}
])
+ ->where('publicado', 1)
->latest()
->get();
diff --git a/back/app/Models/ProcesoAdmision.php b/back/app/Models/ProcesoAdmision.php
index 106c862..a8a117f 100644
--- a/back/app/Models/ProcesoAdmision.php
+++ b/back/app/Models/ProcesoAdmision.php
@@ -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');
+ }
+
}
diff --git a/back/app/Models/ProcesoAdmisionResultadoArchivo.php b/back/app/Models/ProcesoAdmisionResultadoArchivo.php
new file mode 100644
index 0000000..7d9f327
--- /dev/null
+++ b/back/app/Models/ProcesoAdmisionResultadoArchivo.php
@@ -0,0 +1,36 @@
+ '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');
+ }
+}
diff --git a/back/database/migrations/2026_02_20_000001_create_proceso_admision_resultado_archivos_table.php b/back/database/migrations/2026_02_20_000001_create_proceso_admision_resultado_archivos_table.php
new file mode 100644
index 0000000..9ee23d8
--- /dev/null
+++ b/back/database/migrations/2026_02_20_000001_create_proceso_admision_resultado_archivos_table.php
@@ -0,0 +1,29 @@
+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');
+ }
+};
diff --git a/back/routes/api.php b/back/routes/api.php
index e789920..f08369b 100644
--- a/back/routes/api.php
+++ b/back/routes/api.php
@@ -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']);
\ No newline at end of file
+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']);
+});
\ No newline at end of file
diff --git a/front/src/components/WebPageSections/ConvocatoriasSection.vue b/front/src/components/WebPageSections/ConvocatoriasSection.vue
index 59cc59f..8d8fcba 100644
--- a/front/src/components/WebPageSections/ConvocatoriasSection.vue
+++ b/front/src/components/WebPageSections/ConvocatoriasSection.vue
@@ -102,13 +102,30 @@
-
+
+
+
+
Resultados del Examen
+
Consulta los resultados del proceso de admisión vigente
+
+
+
+
+
+
Preinscripción en Línea
Completa tu preinscripción de manera virtual y segura
-
-
diff --git a/front/src/components/nabvar.vue b/front/src/components/nabvar.vue
index 150f7c5..ecad3d3 100644
--- a/front/src/components/nabvar.vue
+++ b/front/src/components/nabvar.vue
@@ -475,9 +475,6 @@ onUnmounted(() => {
.modern-header {
padding: 0 12px !important;
}
- .logo-text span {
- display: none;
- }
.mobile-menu-btn {
font-size: 20px;
padding: 6px;
diff --git a/front/src/router/index.js b/front/src/router/index.js
index e71d22c..e12e39d 100644
--- a/front/src/router/index.js
+++ b/front/src/router/index.js
@@ -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',
diff --git a/front/src/store/procesoAdmisionResultado.store.js b/front/src/store/procesoAdmisionResultado.store.js
new file mode 100644
index 0000000..af6939d
--- /dev/null
+++ b/front/src/store/procesoAdmisionResultado.store.js
@@ -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
+ },
+ },
+})
diff --git a/front/src/store/web.js b/front/src/store/web.js
index 7899991..a5a5576 100644
--- a/front/src/store/web.js
+++ b/front/src/store/web.js
@@ -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
diff --git a/front/src/views/administrador/procesoadmision/ProcesosAdmisionList.vue b/front/src/views/administrador/procesoadmision/ProcesosAdmisionList.vue
index f68495c..14c0ad2 100644
--- a/front/src/views/administrador/procesoadmision/ProcesosAdmisionList.vue
+++ b/front/src/views/administrador/procesoadmision/ProcesosAdmisionList.vue
@@ -128,6 +128,9 @@
+
+ Resultados
+
Detalles
@@ -391,6 +394,70 @@
+
+
+
+
+
+
+ {{ slot }}
+ {{ generarNombreSlot(slot, procesoResultadoActual) }}
+
+
+
+
+
+
+
+
+
+
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; }
diff --git a/install-dev.md b/install-dev.md
index 71c0256..666d923 100644
--- a/install-dev.md
+++ b/install-dev.md
@@ -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.