Merge branch 'main' of github.com:elmer-20/direccion_de_admision_2026

main
commit fb154d38dc

@ -39,6 +39,8 @@ services:
image: mysql:8.0
container_name: admision_prod_db
restart: unless-stopped
ports:
- "127.0.0.1:3306:3306"
environment:
MYSQL_ROOT_PASSWORD: ${DB_PASSWORD}
MYSQL_DATABASE: ${DB_DATABASE:-admision_2026}

@ -0,0 +1,313 @@
<!-- src/components/web/CarrerasSection.vue -->
<template>
<section class="carreras-section">
<div class="container">
<div class="header">
<div class="header-left">
<a-typography-title :level="2" class="title">
Carreras del Área: {{ areaNombre }}
</a-typography-title>
<a-typography-paragraph class="subtitle">
Información del programa, grado/título y resumen
</a-typography-paragraph>
</div>
</div>
<a-divider class="divider" />
<div v-if="filteredCarreras.length">
<a-row :gutter="[18, 18]">
<a-col
v-for="carrera in filteredCarreras"
:key="carrera.id"
:xs="24"
:sm="12"
:lg="8"
class="career-col"
>
<!-- CINTA ARRIBA: ÁREA -->
<a-badge-ribbon :text="areaNombre" color="blue">
<a-card
hoverable
class="card"
:bodyStyle="{ padding: '12px' }"
@click="goToDetail(carrera)"
>
<!-- SOLO 1 IMAGEN: COVER -->
<template v-if="getImage(carrera)" #cover>
<div
class="cover"
:style="{ backgroundImage: `url(${getImage(carrera)})` }"
>
<div class="cover-overlay" />
</div>
</template>
<!-- Contenido -->
<div class="content">
<a-typography-title
:level="5"
class="card-title"
:content="carrera.escuela_profesional"
:ellipsis="{ rows: 2, tooltip: carrera.escuela_profesional }"
/>
<!-- Resumen: 20 palabras + 4 filas -->
<a-typography-paragraph
class="desc"
:title="carrera.resumen_general || 'Sin resumen registrado.'"
>
{{ truncateWords(carrera.resumen_general || "Sin resumen registrado.", 20) }}
</a-typography-paragraph>
<div class="actions">
<a-button type="link" class="read-more" @click.stop="goToDetail(carrera)">
Ver detalle
<ArrowRightOutlined />
</a-button>
<a-tag color="gold" class="tag-soft">
{{ (carrera.perfil_de_egresado?.competencias_especificas?.length || 0) }} CE
</a-tag>
</div>
</div>
</a-card>
</a-badge-ribbon>
</a-col>
</a-row>
</div>
<a-empty
v-else
:description="`No hay carreras registradas para el área: ${areaNombre}.`"
/>
</div>
</section>
</template>
<script setup>
import { computed } from "vue"
import { ArrowRightOutlined } from "@ant-design/icons-vue"
import { useRouter } from "vue-router"
import { carreras } from "../../../../data/carreras"
import { areas } from "../../../../data/areas"
const props = defineProps({
areaId: { type: Number, default: 1 },
})
const router = useRouter()
const areaNombre = computed(() => {
const a = (areas ?? []).find((x) => Number(x.id) === Number(props.areaId))
return a?.nombre || `Área ${props.areaId}`
})
const filteredCarreras = computed(() => {
return (carreras ?? []).filter((c) => Number(c.areaId) === Number(props.areaId))
})
// más robusto: ignora "/", "#", etc.
const getImage = (c) => {
const invalid = new Set(["/", "#", "null", "undefined"])
const candidates = [c?.imagen, c?.imagen1, c?.imagen2, c?.imagen3]
.map((x) => (x ?? "").toString().trim())
.filter(Boolean)
return candidates.find((src) => !invalid.has(src)) || ""
}
const goToDetail = (carrera) => {
router.push({ name: "ingenierias-detalle", params: { id: carrera.id } })
}
const truncateWords = (text, limit = 20) => {
if (!text) return ""
const words = String(text).trim().split(/\s+/).filter(Boolean)
if (words.length <= limit) return words.join(" ")
return words.slice(0, limit).join(" ") + "..."
}
</script>
<style scoped>
/* ✅ AntDV “puro” + institucional: fondo limpio, bordes suaves */
.carreras-section {
padding: 64px 0;
background: #f5f7fa; /* parecido a layout antd */
}
/* ✅ Forzar Times en TODO (incluye AntDV interno) */
.carreras-section :deep(*) {
font-family: "Times New Roman", Times, serif !important;
}
.container {
max-width: 1200px;
margin: 0 auto;
padding: 0 24px;
}
.header {
display: flex;
justify-content: center;
margin-bottom: 10px;
}
.header-left {
width: 100%;
max-width: 880px;
text-align: center;
}
.title {
margin: 0 !important;
font-weight: 900;
color: rgba(0, 0, 0, 0.88);
letter-spacing: -0.2px;
}
.subtitle {
margin: 8px 0 0 !important;
color: rgba(0, 0, 0, 0.60);
line-height: 1.6;
font-size: 0.98rem;
}
.divider {
margin: 16px 0 22px !important;
}
/* columnas parejitas */
.career-col {
display: flex;
}
/* ✅ Card más “institucional” (antd-like) */
.card {
width: 100%;
border-radius: 14px;
border: 1px solid #f0f0f0;
overflow: hidden;
background: #fff;
transition: box-shadow 0.18s ease, transform 0.18s ease;
}
.card:hover {
transform: translateY(-2px);
box-shadow: 0 10px 28px rgba(0, 0, 0, 0.08);
}
/* ✅ Cover más pequeño y limpio */
.cover {
position: relative;
height: 140px;
background-size: cover; /* moderno */
background-position: center;
background-repeat: no-repeat;
background-color: #fafafa;
border-bottom: 1px solid #f0f0f0;
}
.cover-overlay {
position: absolute;
inset: 0;
}
/* pill institucional */
.pill {
position: absolute;
left: 12px;
bottom: 12px;
display: inline-flex;
align-items: center;
gap: 8px;
padding: 7px 10px;
border-radius: 999px;
color: rgba(255, 255, 255, 0.95);
background: rgba(22, 119, 255, 0.45); /* azul antd */
border: 1px solid rgba(255, 255, 255, 0.22);
backdrop-filter: blur(6px);
font-size: 0.86rem;
font-weight: 700;
max-width: calc(100% - 24px);
}
.pill-strong { font-weight: 900; }
.pill-text {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* ✅ contenido con “actions” abajo siempre */
.content {
display: flex;
flex-direction: column;
gap: 8px;
height: 100%;
}
/* título compacto */
.card-title {
margin: 0 !important;
font-size: 1.05rem;
font-weight: 900;
color: rgba(0, 0, 0, 0.88);
min-height: 44px; /* empareja cards */
}
.meta {
display: flex;
align-items: center;
gap: 8px;
}
.tag-soft {
border-radius: 999px;
font-weight: 700;
}
.meta-text {
color: rgba(0, 0, 0, 0.70);
line-height: 1.35;
font-size: 0.92rem;
}
/* ✅ 20 palabras + 4 filas (clamp) */
.desc {
margin: 0 !important;
color: rgba(0, 0, 0, 0.65);
line-height: 1.55;
font-size: 0.94rem;
display: -webkit-box;
-webkit-box-orient: vertical;
overflow: hidden;
min-height: calc(1.55em * 4);
}
/* acciones abajo */
.actions {
margin-top: auto;
display: flex;
justify-content: space-between;
align-items: center;
}
.read-more {
padding: 0;
font-weight: 900;
}
@media (max-width: 768px) {
.carreras-section {
padding: 52px 0;
}
}
</style>

@ -0,0 +1,542 @@
<!-- src/components/web/CarreraDetalle.vue -->
<template>
<NavbarModerno />
<section class="detalle-section">
<div class="container">
<!-- Topbar en card -->
<a-card class="topbar-card" :bodyStyle="{ padding: '12px 14px' }">
<div class="topbar">
<a-space>
<a-button @click="goBack">Volver</a-button>
<a-breadcrumb class="crumb">
<a-breadcrumb-item>Áreas</a-breadcrumb-item>
<a-breadcrumb-item>{{ areaNombre }}</a-breadcrumb-item>
<a-breadcrumb-item>{{ carrera?.escuela_profesional || "Detalle" }}</a-breadcrumb-item>
</a-breadcrumb>
</a-space>
<a-space v-if="carrera">
<a-tag color="blue" class="tag-soft">{{ areaNombre }}</a-tag>
<a-tag color="gold" class="tag-soft">{{ competencias.length }} CE</a-tag>
</a-space>
</div>
</a-card>
<!-- 404 -->
<a-result
v-if="!carrera"
status="404"
title="Carrera no encontrada"
sub-title="No existe un registro con el ID solicitado."
class="mt16"
>
<template #extra>
<a-button type="primary" @click="goBack">Volver</a-button>
</template>
</a-result>
<div v-else>
<!-- HERO -->
<a-card class="hero-card" :bodyStyle="{ padding: 0 }">
<div
v-if="selectedImage"
class="hero"
:style="{ backgroundImage: `url(${selectedImage})` }"
@click="openPreview(selectedImage)"
title="Ver imagen completa"
>
<div class="hero-overlay"></div>
<div class="hero-inner">
<div class="hero-tags">
<a-tag color="blue" class="tag-soft">{{ areaNombre }}</a-tag>
<a-tag color="geekblue" class="tag-soft">{{ carrera.facultad || "Sin facultad" }}</a-tag>
<a-tag color="purple" class="tag-soft">{{ competencias.length }} CE</a-tag>
</div>
<a-typography-title :level="2" class="hero-title">
{{ carrera.escuela_profesional }}
</a-typography-title>
<a-typography-paragraph class="hero-sub">
<b>Programa:</b> {{ carrera.programa_de_estudios || "No registrado" }}
<span class="sep"></span>
<b>Grado:</b> {{ carrera.grado_academico || "No registrado" }}
</a-typography-paragraph>
<div class="hero-actions">
<a-button type="primary" @click.stop="openPreview(selectedImage)">
Ver imagen
</a-button>
<a-button @click.stop="scrollTo('#seccion-competencias')">
Ir a competencias
</a-button>
</div>
<div class="hero-hint">Click en la imagen para ampliar</div>
</div>
</div>
<!-- Fallback sin imagen -->
<div v-else class="hero-empty">
<div class="hero-empty-inner">
<a-typography-title :level="2" class="hero-title-dark">
{{ carrera.escuela_profesional }}
</a-typography-title>
<a-alert type="info" show-icon message="Sin imágenes registradas." />
</div>
</div>
</a-card>
<!-- Layout principal -->
<a-row :gutter="[18, 18]" class="main">
<!-- IZQUIERDA -->
<a-col :xs="24" :lg="8">
<!-- Galería -->
<a-card class="card sticky" :bodyStyle="{ padding: '12px' }">
<div class="card-head">
<div class="accent"></div>
<a-typography-title :level="5" class="h5">Galería</a-typography-title>
</div>
<div class="thumbs" v-if="secondaryImages.length">
<button
v-for="(img, idx) in secondaryImages"
:key="idx"
class="thumb"
type="button"
@click="selectImage(img)"
title="Seleccionar"
>
<div class="thumb-inner" @click.stop="openPreview(img)" title="Ver completa">
<a-image :src="img" :preview="false" class="thumb-img" />
<div class="thumb-badge">Ampliar</div>
</div>
</button>
</div>
<a-empty v-else description="No hay imágenes secundarias." class="empty-small" />
<a-divider class="mini-divider" />
<a-space style="width:100%">
<a-button block type="primary" @click="openPreview(selectedImage)" :disabled="!selectedImage">
Ver principal
</a-button>
</a-space>
</a-card>
<!-- Ficha -->
<a-card class="card mt16" :bodyStyle="{ padding: '12px' }">
<div class="card-head">
<div class="accent"></div>
<a-typography-title :level="5" class="h5">Ficha rápida</a-typography-title>
</div>
<a-descriptions size="small" :column="1" bordered>
<a-descriptions-item label="Área">{{ areaNombre }}</a-descriptions-item>
<a-descriptions-item label="Facultad">{{ carrera.facultad || "No registrada" }}</a-descriptions-item>
<a-descriptions-item label="Programa">{{ carrera.programa_de_estudios || "No registrado" }}</a-descriptions-item>
<a-descriptions-item label="Título">{{ carrera.titulo_profesional || "No registrado" }}</a-descriptions-item>
</a-descriptions>
</a-card>
</a-col>
<!-- DERECHA -->
<a-col :xs="24" :lg="16">
<!-- Resumen -->
<a-card class="card" :bodyStyle="{ padding: '14px' }">
<div class="card-head big">
<div class="accent"></div>
<a-typography-title :level="4" class="h4">Resumen general</a-typography-title>
</div>
<a-typography-paragraph class="p">
{{ carrera.resumen_general || "Sin resumen registrado." }}
</a-typography-paragraph>
</a-card>
<!-- Competencias -->
<a-card class="card mt16" :bodyStyle="{ padding: '14px' }">
<div id="seccion-competencias"></div>
<div class="card-head big">
<div class="accent"></div>
<a-typography-title :level="4" class="h4">Competencias específicas</a-typography-title>
</div>
<a-alert
v-if="!competencias.length"
type="info"
show-icon
message="Sin competencias registradas."
/>
<a-table
v-else
:columns="columns"
:data-source="competencias"
:pagination="{ pageSize: 8, hideOnSinglePage: true }"
size="middle"
rowKey="codigo"
class="table"
/>
</a-card>
</a-col>
</a-row>
</div>
</div>
<!-- Modal imagen completa -->
<a-modal v-model:open="previewVisible" :footer="null" centered :width="980">
<div class="modal-wrap">
<img :src="previewSrc" class="modal-img" alt="Vista completa" />
</div>
</a-modal>
<a-back-top />
</section>
<FooterModerno />
</template>
<script setup>
import { computed, ref, watch } from "vue"
import { useRoute, useRouter } from "vue-router"
import NavbarModerno from "../../../nabvar.vue"
import FooterModerno from "../../../Footer.vue"
import { carreras } from "../../../../data/carreras"
import { areas } from "../../../../data/areas"
const route = useRoute()
const router = useRouter()
const carreraId = computed(() => Number(route.params.id))
const carrera = computed(() => (carreras ?? []).find((c) => Number(c.id) === carreraId.value))
const areaNombre = computed(() => {
const id = carrera.value?.areaId
const a = (areas ?? []).find((x) => Number(x.id) === Number(id))
return a?.nombre || (id != null ? `Área ${id}` : "Área")
})
/** 3 slots (sin dedupe) */
const collectImages = (c) => {
if (!c) return []
const invalid = new Set(["/", "#", "null", "undefined"])
return [c?.imagen1, c?.imagen2, c?.imagen3]
.map((x) => (x ?? "").toString().trim())
.filter((src) => src && !invalid.has(src))
}
const images = computed(() => collectImages(carrera.value))
const selectedImage = ref("")
watch(images, (list) => (selectedImage.value = list?.[0] || ""), { immediate: true })
const secondaryImages = computed(() => images.value.slice(1, 3))
const selectImage = (img) => (selectedImage.value = img)
/** Modal */
const previewVisible = ref(false)
const previewSrc = ref("")
const openPreview = (img) => {
if (!img) return
previewSrc.value = img
previewVisible.value = true
}
/** Competencias */
const competencias = computed(() => carrera.value?.perfil_de_egresado?.competencias_especificas ?? [])
const columns = [
{ title: "Código", dataIndex: "codigo", key: "codigo", width: 120 },
{ title: "Resumen", dataIndex: "resumen", key: "resumen" },
]
const goBack = () => {
if (window.history.length > 1) router.back()
else router.push({ name: "home" })
}
const scrollTo = (selector) => {
const el = document.querySelector(selector)
if (el) el.scrollIntoView({ behavior: "smooth", block: "start" })
}
</script>
<style scoped>
/* Fondo institucional más fino */
.detalle-section {
padding: 28px 0 56px;
background:
radial-gradient(900px 260px at 10% 0%, rgba(22,119,255,0.10) 0%, transparent 60%),
radial-gradient(900px 260px at 90% 0%, rgba(114,46,209,0.08) 0%, transparent 60%),
#f5f7fa;
}
/* Times everywhere */
.detalle-section :deep(*) {
font-family: "Times New Roman", Times, serif !important;
}
.container {
max-width: 1200px;
margin: 0 auto;
padding: 0 24px;
}
/* Topbar card */
.topbar-card {
border-radius: 14px;
border: 1px solid #f0f0f0;
background: rgba(255,255,255,0.92);
backdrop-filter: blur(8px);
}
.topbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
flex-wrap: wrap;
}
.crumb {
max-width: 780px;
overflow: hidden;
}
/* Hero */
.hero-card {
margin-top: 14px;
border-radius: 16px;
border: 1px solid #f0f0f0;
overflow: hidden;
background: #fff;
box-shadow: 0 12px 30px rgba(0,0,0,0.06);
}
.hero {
position: relative;
height: 280px;
background-size: cover;
background-position: center;
cursor: zoom-in;
}
.hero-overlay {
position: absolute;
inset: 0;
background: linear-gradient(
90deg,
rgba(11, 24, 70, 0.78) 0%,
rgba(11, 24, 70, 0.40) 55%,
rgba(11, 24, 70, 0.18) 100%
);
}
.hero-inner {
position: absolute;
inset: 0;
padding: 18px 18px 16px;
display: flex;
flex-direction: column;
justify-content: flex-end;
gap: 10px;
max-width: 920px;
}
.hero-tags {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.tag-soft {
border-radius: 999px;
font-weight: 700;
}
.hero-title {
margin: 0 !important;
color: rgba(255,255,255,0.97);
font-weight: 900;
letter-spacing: -0.2px;
line-height: 1.12;
}
.hero-sub {
margin: 0 !important;
color: rgba(255,255,255,0.90);
font-size: 1rem;
line-height: 1.6;
}
.sep {
margin: 0 10px;
opacity: 0.85;
}
.hero-actions {
display: flex;
gap: 10px;
flex-wrap: wrap;
}
.hero-hint {
margin-top: 2px;
color: rgba(255,255,255,0.85);
font-weight: 700;
font-size: 0.95rem;
}
.hero-empty {
padding: 18px;
}
.hero-empty-inner {
max-width: 900px;
}
.hero-title-dark {
margin: 0 0 10px !important;
font-weight: 900;
}
/* Main layout */
.main {
margin-top: 18px;
}
/* Cards */
.card {
border-radius: 14px;
border: 1px solid #f0f0f0;
background: rgba(255,255,255,0.92);
backdrop-filter: blur(8px);
box-shadow: 0 10px 24px rgba(0,0,0,0.05);
}
.mt16 {
margin-top: 16px;
}
.sticky {
position: sticky;
top: 14px;
}
/* Card headings with accent bar */
.card-head {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 10px;
}
.card-head.big {
margin-bottom: 12px;
}
.accent {
width: 6px;
height: 18px;
border-radius: 999px;
background: rgba(22, 119, 255, 0.85);
}
.h4 {
margin: 0 !important;
font-weight: 900;
color: rgba(0,0,0,0.88);
}
.h5 {
margin: 0 !important;
font-weight: 900;
color: rgba(0,0,0,0.86);
}
.p {
margin: 0 !important;
color: rgba(0,0,0,0.72);
line-height: 1.78;
font-size: 1rem;
}
/* thumbs */
.thumbs {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 10px;
}
.thumb {
padding: 0;
border: 1px solid #f0f0f0;
background: #fff;
border-radius: 12px;
overflow: hidden;
cursor: pointer;
transition: transform 0.15s ease, box-shadow 0.15s ease, border-color 0.15s ease;
}
.thumb:hover {
transform: translateY(-1px);
box-shadow: 0 10px 22px rgba(0,0,0,0.08);
border-color: rgba(22,119,255,0.45);
}
.thumb-inner {
position: relative;
cursor: zoom-in;
}
.thumb-img :deep(img) {
width: 100%;
height: 110px;
object-fit: cover;
display: block;
}
.thumb-badge {
position: absolute;
right: 8px;
bottom: 8px;
padding: 4px 8px;
border-radius: 999px;
background: rgba(0,0,0,0.40);
color: rgba(255,255,255,0.95);
font-weight: 900;
font-size: 0.85rem;
}
.mini-divider {
margin: 12px 0 !important;
}
.empty-small :deep(.ant-empty-description) {
color: rgba(0,0,0,0.55);
}
/* Table */
.table :deep(.ant-table-thead > tr > th) {
font-weight: 900;
}
/* Modal */
.modal-wrap { padding: 6px; }
.modal-img {
width: 100%;
height: auto;
display: block;
border-radius: 12px;
}
@media (max-width: 768px) {
.hero {
height: 230px;
}
.sticky {
position: static;
}
}
</style>

@ -166,7 +166,7 @@ watch(drawerOpen, (open) => {
const routesByKey = {
inicio: "/",
programas: "",
ingenierias: "",
ingenierias: "/programas/ingenierias",
biomedicas: "",
sociales: "",
modalidades: "/modalidades",

@ -0,0 +1,5 @@
export const areas = [
{ id: 1, nombre: "Ingenierías" },
{ id: 2, nombre: "Biomédicas" },
{ id: 3, nombre: "Sociales" }
]

File diff suppressed because it is too large Load Diff
Loading…
Cancel
Save