Historial academico y certificados

This commit is contained in:
jose.lopez 2025-09-26 16:14:51 -06:00
parent a3c6d0e584
commit 9737e4c378
15 changed files with 888 additions and 154 deletions

View File

@ -0,0 +1,83 @@
<script setup>
import { getDate } from '@Controllers/DateController';
import GoogleIcon from '@Shared/GoogleIcon.vue';
/** Props */
const props = defineProps({
records: {
type: Array,
default: () => []
}
});
/** Eventos */
const emit = defineEmits(['destroy', 'edit']);
/** Métodos */
function openDestroy(record) {
emit('destroy', record);
}
function openEdit(record) {
emit('edit', record);
}
</script>
<template>
<div class="mt-6">
<h3 class="text-base font-medium text-gray-800 flex items-center gap-2 dark:text-primary-dt">
<GoogleIcon
class="text-black dark:text-primary-dt text-xl"
name="book"
/>
Grados Académicos
</h3>
<div class="mt-4 grid gap-4 grid-cols-1 md:grid-cols-2">
<!-- Item grado dinámico -->
<article
v-for="record in records"
:key="record.id"
class="rounded-lg border border-gray-100 bg-white p-4 relative dark:bg-primary-d dark:border-primary/20 dark:text-primary-dt"
>
<div class="flex items-start justify-between">
<div>
<h4 class="text-sm font-semibold text-gray-800 dark:text-primary-dt">{{ record.title }}</h4>
<p class="text-xs text-gray-500 mt-2 dark:text-primary-dt/70">{{ record.institution }}</p>
<p class="text-xs text-gray-400 mt-2 dark:text-primary-dt/70">Fecha de obtención: {{ record.date_obtained ? getDate(record.date_obtained) : '-' }}</p>
</div>
<div class="flex items-center gap-2">
<span class="inline-flex items-center px-3 py-1 rounded-full text-xs font-semibold bg-gray-100 text-gray-700 dark:bg-primary/10 dark:text-primary-dt">
{{ record.degree_type_ek }}
</span>
<button
@click="openEdit(record)"
class="p-1 text-blue-600 hover:text-blue-800 dark:text-blue-400 dark:hover:text-blue-300"
title="Editar grado académico"
>
<GoogleIcon name="edit" class="w-4 h-4" />
</button>
<button
@click="openDestroy(record)"
class="p-1 text-red-600 hover:text-red-800 dark:text-red-400 dark:hover:text-red-300"
title="Eliminar grado académico"
>
<GoogleIcon name="delete" class="w-4 h-4" />
</button>
</div>
</div>
</article>
<!-- Estado vacío para grados académicos -->
<div v-if="!records || records.length === 0" class="col-span-2 py-8 text-center">
<div class="text-gray-500 dark:text-primary-dt/70">
<GoogleIcon name="school" class="w-12 h-12 mx-auto mb-4 text-gray-400" />
<p class="text-lg font-medium">No se encontraron grados académicos</p>
<p class="text-sm mt-1">Intenta ajustar los filtros de búsqueda</p>
</div>
</div>
</div>
</div>
</template>

View File

@ -0,0 +1,121 @@
<script setup>
import GoogleIcon from '@Shared/GoogleIcon.vue';
import { getDate } from '@Controllers/DateController';
/** Props */
const props = defineProps({
certifications: {
type: Array,
default: () => []
}
});
/** Eventos */
const emit = defineEmits(['destroy', 'edit']);
/** Métodos */
function openDestroy(cert) {
emit('destroy', cert);
}
function openEdit(cert) {
emit('edit', cert);
}
/** Métodos */
function getCertificationStatus(cert) {
// Si no tiene fecha de expiración, se considera permanente
if (!cert.date_expiration) {
return {
status: 'permanente',
statusText: 'Permanente',
isExpired: false
};
}
const now = new Date();
const expirationDate = new Date(cert.date_expiration);
// Si la fecha actual es mayor a la fecha de expiración, está vencida
const isExpired = now > expirationDate;
return {
status: isExpired ? 'vencida' : 'vigente',
statusText: isExpired ? 'Vencida' : 'Vigente',
isExpired
};
}
</script>
<template>
<div class="mt-8">
<h3 class="text-base font-medium text-gray-800 flex items-center gap-2 dark:text-primary-dt">
<GoogleIcon
class="text-black dark:text-primary-dt text-xl"
name="license"
/>
Certificaciones
</h3>
<div class="mt-4 grid gap-4 grid-cols-1 md:grid-cols-2">
<!-- Certificación dinámica -->
<article
v-for="cert in certifications"
:key="cert.id"
:class="[
'rounded-lg border p-4 relative',
getCertificationStatus(cert).status === 'vigente'
? 'border-gray-100 bg-green-50/40 dark:bg-success-d/10 dark:border-primary/20 dark:text-primary-dt'
: 'border-gray-100 bg-white dark:bg-primary-d dark:border-primary/20 dark:text-primary-dt'
]"
>
<div class="flex items-start justify-between">
<div>
<h4 class="text-sm font-semibold text-gray-800 dark:text-primary-dt">{{ cert.name }}</h4>
<p class="text-xs text-gray-500 mt-2 dark:text-primary-dt/70">{{ cert.institution }}</p>
<p class="text-xs text-gray-500 mt-1 dark:text-primary-dt/70">Obtenida: {{ cert.date_obtained ? getDate(cert.date_obtained) : '-' }}</p>
<p class="text-xs text-gray-500 dark:text-primary-dt/70">Vigencia: {{ cert.date_expiration ? getDate(cert.date_expiration) : 'Permanente' }}</p>
</div>
<div class="flex items-center gap-2">
<span
:class="[
'inline-flex items-center px-3 py-1 rounded-full text-xs font-semibold',
getCertificationStatus(cert).status === 'vigente'
? 'bg-emerald-100 text-emerald-700 dark:bg-success-d dark:text-success-dt'
: getCertificationStatus(cert).status === 'permanente'
? 'bg-blue-100 text-blue-700 dark:bg-info-d dark:text-info-dt'
: 'bg-amber-100 text-amber-800 dark:bg-warning-d dark:text-warning-dt'
]"
>
{{ getCertificationStatus(cert).statusText }}
</span>
<button
@click="openEdit(cert)"
class="p-1 text-blue-600 hover:text-blue-800 dark:text-blue-400 dark:hover:text-blue-300"
title="Editar certificación"
>
<GoogleIcon name="edit" class="w-4 h-4" />
</button>
<button
@click="openDestroy(cert)"
class="p-1 text-red-600 hover:text-red-800 dark:text-red-400 dark:hover:text-red-300"
title="Eliminar certificación"
>
<GoogleIcon name="delete" class="w-4 h-4" />
</button>
</div>
</div>
</article>
<!-- Estado vacío para certificaciones -->
<div v-if="!certifications || certifications.length === 0" class="col-span-2 py-8 text-center">
<div class="text-gray-500 dark:text-primary-dt/70">
<GoogleIcon name="license" class="w-12 h-12 mx-auto mb-4 text-gray-400" />
<p class="text-lg font-medium">No se encontraron certificaciones</p>
<p class="text-sm mt-1">Intenta ajustar los filtros de búsqueda</p>
</div>
</div>
</div>
</div>
</template>

View File

@ -150,6 +150,8 @@ export default {
}, },
dashboard: 'Dashboard', dashboard: 'Dashboard',
date: 'Fecha', date: 'Fecha',
date_expiration: 'Fecha de expiración',
date_obtained: 'Fecha de obtención',
department: 'Departamento', department: 'Departamento',
dates: { dates: {
start: 'Fecha Inicial', start: 'Fecha Inicial',
@ -169,6 +171,7 @@ export default {
confirm: 'Al presionar ELIMINAR el registro se eliminará permanentemente y no podrá recuperarse.', confirm: 'Al presionar ELIMINAR el registro se eliminará permanentemente y no podrá recuperarse.',
title: 'Eliminar', title: 'Eliminar',
}, },
degreeType: 'Tipo de grado',
deleted:'Registro eliminado', deleted:'Registro eliminado',
description:'Descripción', description:'Descripción',
details:'Detalles', details:'Detalles',
@ -205,6 +208,7 @@ export default {
}, },
headquarter: 'Sede', headquarter: 'Sede',
hire_date: 'Fecha de contratación', hire_date: 'Fecha de contratación',
institution: 'Institución',
history: { history: {
title:'Historial de acciones', title:'Historial de acciones',
description:'Historial de acciones realizadas por los usuarios en orden cronológico.' description:'Historial de acciones realizadas por los usuarios en orden cronológico.'
@ -219,6 +223,7 @@ export default {
menu:'Menú', menu:'Menú',
name:'Nombre', name:'Nombre',
noRecords:'Sin registros', noRecords:'Sin registros',
number: 'Número',
notification:'Notificación', notification:'Notificación',
notifications: { notifications: {
unreadClosed:'Ocultas', unreadClosed:'Ocultas',
@ -397,6 +402,28 @@ export default {
unreaded:'No leído', unreaded:'No leído',
user:'Usuario', user:'Usuario',
users:{ users:{
academic: {
create: {
certification: {
description: 'Permite agregar nuevas certificaciones profesionales al historial académico del usuario.',
title: 'Crear certificación'
},
record: {
description: 'Permite agregar nuevos grados académicos al historial del usuario.',
title: 'Crear registro académico'
}
},
edit: {
certification: {
description: 'Permite modificar los datos de la certificación profesional seleccionada.',
title: 'Editar certificación'
},
record: {
description: 'Permite modificar los datos del registro académico seleccionado.',
title: 'Editar registro académico'
}
}
},
activity: { activity: {
title: 'Actividad del usuario', title: 'Actividad del usuario',
description: 'Historial de acciones realizadas por el usuario.', description: 'Historial de acciones realizadas por el usuario.',

View File

@ -55,7 +55,7 @@ onMounted(() => {
<Link <Link
icon="school" icon="school"
name="Historial Académico" name="Historial Académico"
to="admin.users.academic" to="admin.users.academic.index"
/> />
<Link <Link
icon="security" icon="security"

View File

@ -1,147 +0,0 @@
<script setup>
import GoogleIcon from '@Shared/GoogleIcon.vue';
import Adding from '@Holos/Button/ButtonRh.vue';
</script>
<template>
<div class="p-6 max-w-auto mx-auto">
<!-- Página: Header principal -->
<div class="flex items-start justify-between">
<div>
<h1 class="text-4xl font-extrabold text-gray-900 dark:text-primary-dt">Historial Académico</h1>
<p class="mt-1 text-sm text-gray-500 dark:text-primary-dt/70">Gestión de grados académicos y certificaciones profesionales</p>
</div>
<div>
<Adding text="Agregar Registro" />
</div>
</div>
<!-- Card principal: perfil + secciones -->
<section class="mt-6 bg-white rounded-lg shadow-sm p-6 dark:bg-primary-d dark:border-primary/20 dark:text-primary-dt">
<!-- Perfil -->
<header class="flex items-start gap-4">
<div class="flex-shrink-0">
<div class="w-12 h-12 rounded-full bg-gray-100 flex items-center justify-center text-gray-600 dark:bg-primary/10 dark:text-primary-dt">
<!-- icono usuario -->
<GoogleIcon
class="text-black dark:text-primary-dt text-xl"
name="school"
/>
</div>
</div>
<div class="flex-1">
<h2 class="text-xl font-semibold text-gray-800 dark:text-primary-dt">María González López</h2>
<p class="text-sm text-gray-500 mt-1 dark:text-primary-dt/70">Información académica y certificaciones profesionales</p>
</div>
</header>
<!-- Secciones: Grados Académicos -->
<div class="mt-6">
<h3 class="text-base font-medium text-gray-800 flex items-center gap-2 dark:text-primary-dt">
<GoogleIcon
class="text-black dark:text-primary-dt text-xl"
name="book"
/>
Grados Académicos
</h3>
<div class="mt-4 grid gap-4 grid-cols-1 md:grid-cols-2">
<!-- Item grado -->
<article class="rounded-lg border border-gray-100 bg-white p-4 relative dark:bg-primary-d dark:border-primary/20 dark:text-primary-dt">
<div class="flex items-start justify-between">
<div>
<h4 class="text-sm font-semibold text-gray-800 dark:text-primary-dt">Licenciatura en Ingeniería en Sistemas</h4>
<p class="text-xs text-gray-500 mt-2 dark:text-primary-dt/70">Universidad Nacional Autónoma de México</p>
<p class="text-xs text-gray-400 mt-2 dark:text-primary-dt/70">Año: 2015</p>
</div>
<span class="ml-4 inline-flex items-center px-3 py-1 rounded-full text-xs font-semibold bg-gray-100 text-gray-700 dark:bg-primary/10 dark:text-primary-dt">
Licenciatura
</span>
</div>
</article>
<article class="rounded-lg border border-gray-100 bg-white p-4 relative dark:bg-primary-d dark:border-primary/20 dark:text-primary-dt">
<div class="flex items-start justify-between">
<div>
<h4 class="text-sm font-semibold text-gray-800 dark:text-primary-dt">Maestría en Ciencias de la Computación</h4>
<p class="text-xs text-gray-500 mt-2 dark:text-primary-dt/70">Instituto Tecnológico de Monterrey</p>
<p class="text-xs text-gray-400 mt-2 dark:text-primary-dt/70">Año: 2018</p>
</div>
<span class="ml-4 inline-flex items-center px-3 py-1 rounded-full text-xs font-semibold bg-gray-100 text-gray-700 dark:bg-primary/10 dark:text-primary-dt">
Maestría
</span>
</div>
</article>
</div>
</div>
<!-- Secciones: Certificaciones -->
<div class="mt-8">
<h3 class="text-base font-medium text-gray-800 flex items-center gap-2 dark:text-primary-dt">
<GoogleIcon
class="text-black dark:text-primary-dt text-xl"
name="license"
/>
Certificaciones
</h3>
<div class="mt-4 grid gap-4 grid-cols-1 md:grid-cols-2">
<!-- Cert vigente -->
<article class="rounded-lg border border-gray-100 bg-green-50/40 p-4 relative dark:bg-success-d/10 dark:border-primary/20 dark:text-primary-dt">
<div class="flex items-start justify-between">
<div>
<h4 class="text-sm font-semibold text-gray-800 dark:text-primary-dt">AWS Certified Solutions Architect</h4>
<p class="text-xs text-gray-500 mt-2 dark:text-primary-dt/70">Amazon Web Services</p>
<p class="text-xs text-gray-500 mt-1 dark:text-primary-dt/70">Obtenida: 2023-05-15</p>
<p class="text-xs text-gray-500 dark:text-primary-dt/70">Vigencia: 2026-05-15</p>
</div>
<span class="ml-4 inline-flex items-center px-3 py-1 rounded-full text-xs font-semibold bg-emerald-100 text-emerald-700 dark:bg-success-d dark:text-success-dt">
Vigente
</span>
</div>
</article>
<!-- Cert vencida -->
<article class="rounded-lg border border-gray-100 bg-white p-4 relative dark:bg-primary-d dark:border-primary/20 dark:text-primary-dt">
<div class="flex items-start justify-between">
<div>
<h4 class="text-sm font-semibold text-gray-800 dark:text-primary-dt">Certified ScrumMaster</h4>
<p class="text-xs text-gray-500 mt-2 dark:text-primary-dt/70">Scrum Alliance</p>
<p class="text-xs text-gray-500 mt-1 dark:text-primary-dt/70">Obtenida: 2022-08-20</p>
<p class="text-xs text-gray-500 dark:text-primary-dt/70">Vigencia: 2024-08-20</p>
</div>
<span class="ml-4 inline-flex items-center px-3 py-1 rounded-full text-xs font-semibold bg-amber-100 text-amber-800 dark:bg-warning-d dark:text-warning-dt">
Vencida
</span>
</div>
</article>
</div>
</div>
<!-- Acciones al final de la tarjeta -->
<div class="mt-6 border-t border-gray-100 pt-4 flex gap-3 dark:border-primary/20">
<button class="inline-flex items-center gap-2 px-3 py-2 rounded-md border border-gray-200 bg-white text-sm text-gray-700 shadow-sm dark:bg-primary-d dark:border-primary/20 dark:text-primary-dt">
<GoogleIcon
class="text-black dark:text-primary-dt text-xl"
name="add"
/>
Agregar Grado
</button>
<button class="inline-flex items-center gap-2 px-3 py-2 rounded-md border border-gray-200 bg-white text-sm text-gray-700 shadow-sm dark:bg-primary-d dark:border-primary/20 dark:text-primary-dt">
<GoogleIcon
class="text-black dark:text-primary-dt text-xl"
name="add"
/>
Agregar Certificación
</button>
</div>
</section>
</div>
</template>

View File

@ -0,0 +1,58 @@
<script setup>
import { useRouter, useRoute } from 'vue-router';
import { useForm } from '@Services/Api';
import { apiTo, transl, viewTo } from './Module';
import IconButton from '@Holos/Button/Icon.vue'
import PageHeader from '@Holos/PageHeader.vue';
import FormCertification from './FormCertification.vue';
/** Definidores */
const router = useRouter();
const route = useRoute();
/** Propiedades */
const form = useForm({
name: '',
description: '',
institution: '',
date_obtained: '',
date_expiration: '',
number: '',
user_id: route.params.id || '',
});
/** Métodos */
function submit() {
form.post(apiTo('store-certificate'), {
onSuccess: () => {
Notify.success(Lang('register.create.onSuccess'))
router.push(viewTo({ name: 'index' }));
}
})
}
</script>
<template>
<PageHeader
:title="transl('create.certification.title')"
>
<RouterLink :to="viewTo({ name: 'index' })">
<IconButton
class="text-white"
icon="arrow_back"
:title="$t('return')"
filled
/>
</RouterLink>
</PageHeader>
<div class="w-full pb-2">
<p class="text-justify text-sm" v-text="transl('create.certification.description')" />
</div>
<FormCertification
:form="form"
@submit="submit"
/>
</template>

View File

@ -0,0 +1,82 @@
<script setup>
import { onMounted, ref } from 'vue';
import { useRouter, useRoute } from 'vue-router';
import { api, useForm } from '@Services/Api';
import { apiTo, transl, viewTo } from './Module';
import IconButton from '@Holos/Button/Icon.vue'
import PageHeader from '@Holos/PageHeader.vue';
import Selectable from '@Holos/Form/Selectable.vue';
import FormRecord from './FormRecord.vue';
/** Definidores */
const router = useRouter();
const route = useRoute();
/** Propiedades */
const form = useForm({
title: '',
institution: '',
date_obtained: '',
degree_type_ek: '',
user_id: route.params.id || '',
// file_path: null,
});
const degreeTypes = ref([]);
/** Métodos */
function submit() {
form.transform(data => ({
...data,
degree_type_ek: form.degree_type_ek?.id
})).post(apiTo('store-record'), {
onSuccess: () => {
Notify.success(Lang('register.create.onSuccess'))
router.push(viewTo({ name: 'index' }));
}
})
}
/** Ciclos */
onMounted(() => {
api.catalog({
'degreeType:all': null,
},
{
onSuccess: (r) => {
degreeTypes.value = r['degreeType:all'] ?? [];
}
});
})
</script>
<template>
<PageHeader
:title="transl('create.record.title')"
>
<RouterLink :to="viewTo({ name: 'index' })">
<IconButton
class="text-white"
icon="arrow_back"
:title="$t('return')"
filled
/>
</RouterLink>
</PageHeader>
<div class="w-full pb-2">
<p class="text-justify text-sm" v-text="transl('create.record.description')" />
</div>
<FormRecord
:degreeTypes="degreeTypes"
:form="form"
@submit="submit"
>
<Selectable
v-model="form.degree_type_ek"
title="degreeType"
:options="degreeTypes"
/>
</FormRecord>
</template>

View File

@ -0,0 +1,75 @@
<script setup>
import { onMounted, ref } from 'vue';
import { useRouter, useRoute } from 'vue-router';
import { api, useForm } from '@Services/Api';
import { apiTo, transl, viewTo } from './Module';
import IconButton from '@Holos/Button/Icon.vue'
import PageHeader from '@Holos/PageHeader.vue';
import FormCertification from './FormCertification.vue';
/** Definidores */
const router = useRouter();
const vroute = useRoute();
/** Propiedades */
const form = useForm({
name: '',
description: '',
institution: '',
date_obtained: '',
date_expiration: '',
number: '',
user_id: vroute.params.id || '',
});
/** Métodos */
function submit() {
form.put(apiTo('update-certificate', { certificate: vroute.params.certification }), {
onSuccess: () => {
Notify.success(Lang('register.edit.onSuccess'))
router.push(viewTo({ name: 'index' }));
}
})
}
/** Ciclos */
onMounted(() => {
api.get(apiTo('show-certificate', { certificate: vroute.params.certification }), {
onSuccess: (r) => {
form.fill(r.certificate)
if (r.certificate.date_obtained) {
form.date_obtained = new Date(r.certificate.date_obtained).toISOString().split('T')[0]
}
if (r.certificate.date_expiration) {
form.date_expiration = new Date(r.certificate.date_expiration).toISOString().split('T')[0]
}
}
});
})
</script>
<template>
<PageHeader
:title="transl('edit.certification.title')"
>
<RouterLink :to="viewTo({ name: 'index' })">
<IconButton
class="text-white"
icon="arrow_back"
:title="$t('return')"
filled
/>
</RouterLink>
</PageHeader>
<div class="w-full pb-2">
<p class="text-justify text-sm" v-text="transl('edit.certification.description')" />
</div>
<FormCertification
action="update"
:form="form"
@submit="submit"
/>
</template>

View File

@ -0,0 +1,97 @@
<script setup>
import { onMounted, ref } from 'vue';
import { useRouter, useRoute } from 'vue-router';
import { api, useForm } from '@Services/Api';
import { apiTo, transl, viewTo } from './Module';
import IconButton from '@Holos/Button/Icon.vue'
import PageHeader from '@Holos/PageHeader.vue';
import Selectable from '@Holos/Form/Selectable.vue';
import FormRecord from './FormRecord.vue';
/** Definidores */
const router = useRouter();
const vroute = useRoute();
/** Propiedades */
const form = useForm({
title: '',
institution: '',
date_obtained: '',
degree_type_ek: '',
user_id: vroute.params.id || '',
// file_path: null,
});
const degreeTypes = ref([]);
/** Métodos */
function submit() {
form.transform(data => ({
...data,
degree_type_ek: form.degree_type_ek?.id
})).put(apiTo('update-record', { academicRecord: vroute.params.record }), {
onSuccess: () => {
Notify.success(Lang('register.edit.onSuccess'))
router.push(viewTo({ name: 'index' }));
}
})
}
function fillForm() {
api.get(apiTo('show-record', { academicRecord: vroute.params.record }), {
onSuccess: (r) => {
form.fill(r.academic_record)
form.degree_type_ek = degreeTypes.value.find(type => type.name == form.degree_type_ek)
if (r.academic_record.date_obtained) {
form.date_obtained = new Date(r.academic_record.date_obtained).toISOString().split('T')[0]
}
}
});
}
/** Ciclos */
onMounted(() => {
api.catalog({
'degreeType:all': null,
},
{
onSuccess: (r) => {
degreeTypes.value = r['degreeType:all'] ?? [];
// De esta manera se puede rellenar el form con el degree_type_ek del catalogo
fillForm();
}
});
})
</script>
<template>
<PageHeader
:title="transl('edit.record.title')"
>
<RouterLink :to="viewTo({ name: 'index' })">
<IconButton
class="text-white"
icon="arrow_back"
:title="$t('return')"
filled
/>
</RouterLink>
</PageHeader>
<div class="w-full pb-2">
<p class="text-justify text-sm" v-text="transl('edit.record.description')" />
</div>
<FormRecord
:degreeTypes="degreeTypes"
:form="form"
@submit="submit"
>
<Selectable
action="update"
v-model="form.degree_type_ek"
title="degreeType"
:options="degreeTypes"
/>
</FormRecord>
</template>

View File

@ -0,0 +1,76 @@
<script setup>
import PrimaryButton from '@Holos/Button/Primary.vue';
import Input from '@Holos/Form/Input.vue';
import Textarea from '@Holos/Form/Textarea.vue';
const emit = defineEmits([
'submit'
])
defineProps({
action: {
default: 'create',
type: String
},
form: Object
})
const submit = () => {
emit('submit')
}
</script>
<template>
<div class="w-full">
<form @submit.prevent="submit" class="grid gap-4 grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
<Input
v-model="form.name"
id="name"
:onError="form.errors.name"
autofocus
required
/>
<Input
v-model="form.institution"
id="institution"
:onError="form.errors.institution"
required
/>
<Input
v-model="form.number"
id="number"
:onError="form.errors.number"
required
/>
<Input
v-model="form.date_obtained"
id="date_obtained"
:onError="form.errors.date_obtained"
type="date"
required
/>
<Input
v-model="form.date_expiration"
id="date_expiration"
:onError="form.errors.date_expiration"
type="date"
/>
<div class="col-span-1 md:col-span-2 lg:col-span-3 xl:col-span-4">
<Textarea
v-model="form.description"
id="description"
:onError="form.errors.description"
rows="4"
/>
</div>
<div class="col-span-1 md:col-span-2 lg:col-span-3 xl:col-span-4 flex flex-col items-center justify-end space-y-4 mt-4">
<PrimaryButton
v-text="$t('create')"
:class="{ 'opacity-25': form.processing }"
:disabled="form.processing"
/>
</div>
</form>
</div>
</template>

View File

@ -0,0 +1,64 @@
<script setup>
import PrimaryButton from '@Holos/Button/Primary.vue';
import Input from '@Holos/Form/Input.vue';
import SingleFile from '@Holos/Form/SingleFile.vue';
const emit = defineEmits([
'submit'
])
defineProps({
action: {
default: 'create',
type: String
},
form: Object
})
const submit = () => {
emit('submit')
}
</script>
<template>
<div class="w-full">
<form @submit.prevent="submit" class="grid gap-4 grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
<Input
v-model="form.title"
id="title"
:onError="form.errors.title"
autofocus
required
/>
<Input
v-model="form.institution"
id="institution"
:onError="form.errors.institution"
required
/>
<Input
v-model="form.date_obtained"
id="date_obtained"
:onError="form.errors.date_obtained"
type="date"
required
/>
<!-- <div class="col-span-1 md:col-span-2 lg:col-span-3 xl:col-span-4">
<SingleFile
v-model="form.file_path"
title="Archivo"
accept="application/pdf,image/png,image/jpeg"
/>
</div> -->
<slot />
<div class="col-span-1 md:col-span-2 lg:col-span-3 xl:col-span-4 flex flex-col items-center justify-end space-y-4 mt-4">
<PrimaryButton
v-text="$t('create')"
:class="{ 'opacity-25': form.processing }"
:disabled="form.processing"
/>
</div>
</form>
</div>
</template>

View File

@ -0,0 +1,151 @@
<script setup>
import { onMounted, ref } from 'vue';
import { useRouter } from 'vue-router';
import { useSearcher } from '@Services/Api';
import { getDate } from '@Controllers/DateController';
import { apiTo, viewTo } from './Module'
import GoogleIcon from '@Shared/GoogleIcon.vue';
import Searcher from '@Holos/Searcher.vue';
import AcademicRecords from '@Holos/Card/AcademicRecords.vue';
import Certifications from '@Holos/Card/Certifications.vue';
import DestroyView from '@Holos/Modal/Template/Destroy.vue';
/** Propiedades */
const models = ref([]);
/** Definidores */
const router = useRouter();
/** Referencias */
const destroyRecordModal = ref(false);
const destroyCertModal = ref(false);
/** Métodos */
const searcher = useSearcher({
url: apiTo('index'),
onSuccess: (r) => models.value = r.models,
onError: () => models.value = []
});
function openDestroyRecord(record) {
destroyRecordModal.value.open(record);
}
function openDestroyCert(cert) {
destroyCertModal.value.open(cert);
}
function openEditRecord(record) {
router.push(viewTo({ name: 'editRecord', params: { id: record.user_id, record: record.id } }));
}
function openEditCert(cert) {
router.push(viewTo({ name: 'editCertification', params: { id: cert.user_id, certification: cert.id } }));
}
/** Ciclos */
onMounted(() => {
searcher.search();
});
</script>
<template>
<div class="p-6 max-w-auto mx-auto">
<!-- Página: Header principal -->
<div class="flex items-start justify-between">
<div>
<h1 class="text-4xl font-extrabold text-gray-900 dark:text-primary-dt">Historial Académico</h1>
<p class="mt-1 text-sm text-gray-500 dark:text-primary-dt/70">Gestión de grados académicos y certificaciones profesionales</p>
</div>
</div>
<!-- Search Card -->
<div class="pt-2 w-full">
<Searcher @search="(x) => searcher.search(x)">
</Searcher>
</div>
<!-- Cards principales: una por usuario -->
<section
v-for="user in models.data"
:key="user.id"
class="mt-6 bg-white rounded-lg shadow-sm p-6 dark:bg-primary-d dark:border-primary/20 dark:text-primary-dt"
>
<!-- Perfil -->
<header class="flex items-start gap-4">
<div class="flex-shrink-0">
<div class="w-12 h-12 rounded-full bg-gray-100 flex items-center justify-center text-gray-600 dark:bg-primary/10 dark:text-primary-dt">
<!-- icono usuario -->
<GoogleIcon
class="text-black dark:text-primary-dt text-xl"
name="school"
/>
</div>
</div>
<div class="flex-1">
<h2 class="text-xl font-semibold text-gray-800 dark:text-primary-dt">{{ user.full_name }}</h2>
<p class="text-sm text-gray-500 mt-1 dark:text-primary-dt/70">Información académica y certificaciones profesionales</p>
</div>
</header>
<!-- Secciones: Grados Académicos -->
<AcademicRecords
:records="user.academic_records || []"
@destroy="openDestroyRecord"
@edit="openEditRecord"
/>
<!-- Secciones: Certificaciones -->
<Certifications
:certifications="user.certificates || []"
@destroy="openDestroyCert"
@edit="openEditCert"
/>
<!-- Acciones al final de la tarjeta -->
<div class="mt-6 border-t border-gray-100 pt-4 flex gap-3 dark:border-primary/20">
<RouterLink :to="viewTo({ name: 'createRecord', params: { id: user.id } })" class="inline-flex items-center gap-2 px-3 py-2 rounded-md border border-gray-200 bg-white text-sm text-gray-700 shadow-sm dark:bg-primary-d dark:border-primary/20 dark:text-primary-dt">
<GoogleIcon
class="text-black dark:text-primary-dt text-xl"
name="add"
/>
Agregar Grado
</RouterLink>
<RouterLink :to="viewTo({ name: 'createCertification', params: { id: user.id } })" class="inline-flex items-center gap-2 px-3 py-2 rounded-md border border-gray-200 bg-white text-sm text-gray-700 shadow-sm dark:bg-primary-d dark:border-primary/20 dark:text-primary-dt">
<GoogleIcon
class="text-black dark:text-primary-dt text-xl"
name="add"
/>
Agregar Certificación
</RouterLink>
</div>
</section>
<!-- Estado vacío general -->
<div v-if="models.length === 0" class="mt-6 py-12 text-center">
<div class="text-gray-500 dark:text-primary-dt/70">
<GoogleIcon name="person_search" class="w-12 h-12 mx-auto mb-4 text-gray-400" />
<p class="text-lg font-medium">No se encontraron usuarios</p>
<p class="text-sm mt-1">Intenta ajustar los filtros de búsqueda</p>
</div>
</div>
<!-- Modales de eliminación -->
<DestroyView
ref="destroyRecordModal"
subtitle="title"
:to="(academicRecord) => apiTo('destroy-record', { academicRecord })"
@update="searcher.search()"
/>
<DestroyView
ref="destroyCertModal"
subtitle="name"
:to="(certificate) => apiTo('destroy-certificate', { certificate })"
@update="searcher.search()"
/>
</div>
</template>

View File

@ -0,0 +1,21 @@
import { lang } from '@Lang/i18n';
import { hasPermission } from '@Plugins/RolePermission.js';
// Ruta API
const apiTo = (name, params = {}) => route(`admin.users.academic.${name}`, params)
// Ruta visual
const viewTo = ({ name = '', params = {}, query = {} }) => view({ name: `admin.users.academic.${name}`, params, query })
// Obtener traducción del componente
const transl = (str) => lang(`users.academic.${str}`)
// Determina si un usuario puede hacer algo no en base a los permisos
const can = (permission) => hasPermission(`users.academic.${permission}`)
export {
can,
viewTo,
apiTo,
transl
}

View File

@ -58,8 +58,7 @@ onMounted(() => {
onSuccess: (r) => { onSuccess: (r) => {
departments.value = r['department:all'] ?? []; departments.value = r['department:all'] ?? [];
} }
} });
);
}) })
</script> </script>

View File

@ -264,17 +264,44 @@ const router = createRouter({
}, },
}, },
{ {
path: 'admin/users/academic', path: 'academic',
name: 'admin.users.academic', name: 'admin.users.academic',
component: () => import('@Pages/Admin/Users/Academic.vue'), redirect: '/admin/users/academic',
children: [
{
path: '',
name: 'admin.users.academic.index',
component: () => import('@Pages/Admin/Users/Academic/Index.vue'),
}, },
{ {
path: 'admin/users/security', path: ':id/create-record',
name: 'admin.users.academic.createRecord',
component: () => import('@Pages/Admin/Users/Academic/CreateRecord.vue'),
},
{
path: ':id/create-certification',
name: 'admin.users.academic.createCertification',
component: () => import('@Pages/Admin/Users/Academic/CreateCertification.vue'),
},
{
path: ':id/edit-record/:record',
name: 'admin.users.academic.editRecord',
component: () => import('@Pages/Admin/Users/Academic/EditRecord.vue'),
},
{
path: ':id/edit-certification/:certification',
name: 'admin.users.academic.editCertification',
component: () => import('@Pages/Admin/Users/Academic/EditCertification.vue'),
},
]
},
{
path: 'security',
name: 'admin.users.security', name: 'admin.users.security',
component: () => import('@Pages/Admin/Users/Security.vue'), component: () => import('@Pages/Admin/Users/Security.vue'),
}, },
{ {
path: 'admin/users/additional', path: 'additional',
name: 'admin.users.additional', name: 'admin.users.additional',
component: () => import('@Pages/Admin/Users/Additional.vue'), component: () => import('@Pages/Admin/Users/Additional.vue'),
}, },