Esta guía establece las mejores prácticas y estándares de código para el desarrollo frontend en RedCollege. Seguir estas recomendaciones nos ayudará a mantener un código consistente, legible y fácil de mantener, mejorando la eficiencia del equipo y la calidad del producto.
Nuestro stack tecnológico principal incluye:
Nuxt 3 : Framework de Vue.js para desarrollo universalPug : Preprocesador HTML para plantillas más concisasTypeScript : Tipado estático para JavaScriptTailwind CSS : Framework de utilidades CSS@redcollege/ui-nuxt-module : Módulo Nuxt centralizado con componentes personalizados basados en shadcn/vuepages/
├── index.vue
└── [establecimientoid]/
└── planificaciones/
├── index.vue
└── [id].vue
components/
├── ui/ # Componentes básicos (botones, inputs, etc.)
└── domain/ # Componentes específicos del dominio
composables/
└── use[NombreFuncionalidad].ts
layouts/
└── default.vue
Cada componente debe seguir este orden:
Imports Props y Emits Variables y Referencias Computados Funciones Hooks de ciclo de vida Watchers ✅ Correcto:
import type { IUsuario } from '@redcollege/ui-nuxt-module/runtime/models/Auth/usuario'
import type { IPlanificacion } from '@redcollege/ui-nuxt-module/runtime/models'
❌ Incorrecto:
import { IUsuario } from '@redcollege/ui-nuxt-module/runtime/models/Auth/usuario'
✅ Correcto:
interface Props {
planificacion : IPlanificacion
isEditable ?: boolean
}
const props = defineProps < Props >()
❌ Incorrecto:
const props = defineProps <{
planificacion : IPlanificacion ,
isEditable ?: boolean
}>()
Usar siempre arrow functions para mantener consistencia y evitar problemas con el contexto this.
✅ Correcto:
const handleClick = ( id : number ) : void => {
console. log ( `Elemento ${ id } clickeado` )
}
const calcularTotal = ( items : IItem []) : number => {
return items. reduce (( total , item ) => total + item.precio, 0 )
}
❌ Incorrecto:
function handleClick ( id ) {
console. log ( `Elemento ${ id } clickeado` )
}
Evitar el uso de as cuando sea posible. Preferir tipos genéricos.
✅ Correcto:
const resultado = ref < IResultado | null >( null )
❌ Incorrecto:
const resultado = ref ( null as IResultado | null )
Crear composables para lógica reutilizable con nombres descriptivos que empiecen con "use".
✅ Correcto:
// composables/useEstablecimiento.ts
export const useEstablecimiento = () => {
const route = useRoute ()
const establecimientoId = computed (() => Number (route.params.establecimientoid))
const fetchData = async () : Promise < IEstablecimiento | null > => {
// Lógica para obtener datos
}
return {
establecimientoId,
fetchData
}
}
Usar propiedades computadas para derivar valores en lugar de métodos.
✅ Correcto:
const nombreCompleto = computed (() : string => {
return `${ usuario . nombre } ${ usuario . apellido }`
})
❌ Incorrecto:
const getNombreCompleto = () : string => {
return `${ usuario . nombre } ${ usuario . apellido }`
}
Preferir async/await en lugar de promises encadenadas y utilizar Promise.all para múltiples peticiones simultáneas.
✅ Correcto:
const { data , execute } = await useAsyncData ( async () => {
const [ nivelesSige , unidad , planificacion , contenido ] = await Promise . all ([
$apis.establecimiento.curso. getGradosSige ( Number (establecimientoid), Number (props.planificacion.periodo)),
$apis.planificaciones.unidades. getUnidadById ( Number (unidadid)),
$apis.planificaciones.planificacion. getPlanificacionById ( Number (props.planificacion.id)),
$apis.planificaciones.contenidos. obtenerContenidoPorId ( Number (contenidoid))
])
return { nivelesSige, unidad, planificacion, contenido }
})
❌ Incorrecto:
const { data } = await useAsyncData ( async () => {
const nivelesSige = await $apis.establecimiento.curso. getGradosSige ( Number (establecimientoid))
const unidad = await $apis.planificaciones.unidades. getUnidadById ( Number (unidadid))
const planificacion = await $apis.planificaciones.planificacion. getPlanificacionById ( Number (planificacionid))
return { nivelesSige, unidad, planificacion }
})
Mantener una indentación consistente de 2 espacios.
✅ Correcto:
div .container
h1 .title Planificaciones
.card-list
CardPlanificacion (
v-for = " plan in planificaciones "
: key = " plan.id "
: planificacion = " plan "
@ click = " seleccionarPlan (plan.id) "
)
Colocar cada atributo en una nueva línea cuando hay más de 5.
✅ Correcto:
CommandItem (
v-for = " curso in listaCursosFiltrada "
: key = " curso.id "
: value = " curso "
@ select . prevent = " () => toggleCurso (curso) "
class = "cursor-pointer"
) {{ curso.nombre }}
❌ Incorrecto:
CommandItem ( v-for = " curso in listaCursosFiltrada " : key = " curso.id " : value = " curso " @ select . prevent = " () => toggleCurso (curso) " class = "cursor-pointer" ) {{ curso.nombre }}
Nunca usar v-for y v-if en el mismo elemento. Usar un elemento contenedor o computed property.
✅ Correcto:
template ( v-for = " item in items " : key = " item.id " )
div ( v-if = " item.visible " ) {{ item.nombre }}
Encadenar clases de Tailwind con sintaxis de puntos cuando no contienen caracteres especiales.
✅ Correcto:
div .flex.flex-col.gap-4.p-6.rounded-lg
h2 .text-xl.font-bold Título
p .text-gray-500 Descripción
Solo usar el atributo class cuando hay caracteres especiales o variantes:
div ( class = "grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4" )
button ( class = "bg-primary hover:bg-primary/80" )
span ( class = "w-[200px] h-[50px]" )
Organizar las clases siguiendo un orden lógico:
Layout (display, position) Spacing (margin, padding) Sizing (width, height) Typography (font, text) Visual (colors, borders) Interactions (hover, transitions) ✅ Correcto:
div .flex.flex-col.p-4.gap-2.w-full.text-sm.font-medium.bg-white.rounded-lg.shadow ( class = "hover:shadow-md" )
Usar el enfoque mobile-first de Tailwind.
✅ Correcto:
div ( class = "grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4" )
Usar clases semánticas de Tailwind en lugar de valores hexadecimales directos.
✅ Correcto:
button .bg-primary.text-primary-foreground ( class = "hover:bg-primary/80" )
❌ Incorrecto:
button ( class = "bg-[#3B82F6] hover:bg-[#60A5FA] text-white" )
Utilizar el objeto $apis del módulo nuxt para todas las llamadas a API.
✅ Correcto:
const { data : usuarios } = await useAsyncData ( 'usuarios' , () => {
return $apis.usuarios. getAll (filtros)
})
Siempre utilizar useAsyncData para la carga inicial de datos en lugar de onMounted o fetch.
✅ Correcto:
const { data : planificaciones , pending , refresh , error } = await useAsyncData (
'planificaciones' ,
() => $apis.planificaciones.planificacion. getPlanificacionesByEstablecimiento (establecimientoId)
)
// Si necesitas transformar los datos después de la carga
const planificacionesOrdenadas = computed (() => {
if ( ! planificaciones.value) return []
return [ ... planificaciones.value]. sort (( a , b ) => new Date (b.fechaCreacion) - new Date (a.fechaCreacion))
})
❌ Incorrecto:
const planificaciones = ref < IPlanificacion []>([])
const isLoading = ref < boolean >( true )
const cargarPlanificaciones = async () : Promise < void > => {
try {
isLoading.value = true
planificaciones.value = await $apis.planificaciones.planificacion. getPlanificacionesByEstablecimiento (establecimientoId)
} catch (error) {
// Manejo de error
} finally {
isLoading.value = false
}
}
onMounted (() => {
cargarPlanificaciones ()
})
Utilizar el esquema de validación con zod para formularios.
✅ Correcto:
const formSchema = toTypedSchema (z. object ({
titulo: z. string ({
required_error: "El nombre es requerido"
}). trim (). min ( 1 , "El nombre no puede estar vacío" ),
periodo: z. string ({
invalid_type_error: "El año es requerido"
}),
asignaturas: z. array (z. string ()). min ( 1 , {
message: "Debe seleccionar al menos una asignatura"
}). default ([])
}))
const form = useForm ({
validationSchema: formSchema,
initialValues
})
Usar funciones dedicadas para el manejo de eventos de formulario.
✅ Correcto:
const onSubmit = form. handleSubmit ( async ( values ) => {
try {
if (isEdit.value) {
await actualizarRegistro (values)
} else {
await crearRegistro (values)
}
useNotification (). toast ({
title: 'Todo correcto' ,
description: 'Operación exitosa'
})
} catch (e) {
useNotification (). toast ({
title: 'Algo ha salido mal' ,
description: 'No se ha podido completar la operación' ,
variant: 'destructive'
})
}
})
< script setup lang = "ts" >
import type { ICursoAsignatura, ICurso, IPlanificacion } from '@redcollege/ui-nuxt-module/runtime/models'
import type { IUsuario } from '@redcollege/ui-nuxt-module/runtime/models/Auth/usuario'
// Props y emits
interface Props {
planificacion ?: IPlanificacion
}
const props = defineProps < Props >()
const emit = defineEmits ([ 'update' , 'nextStep' , 'refreshDataTable' ])
const { $apis } = useNuxtApp ()
const establecimientoId = Number ( useRoute ()?.params?.establecimientoid)
// Variables
const isEdit = ref < boolean >( !! props.planificacion)
const loaderRef = ref < boolean >( false )
const asignaturasSeleccionadas = ref < string []>([])
const listaAsignaturas = ref < ICursoAsignatura []>([])
// Formulario
const formSchema = toTypedSchema (z. object ({
titulo: z. string ({
required_error: "El nombre de la planificación es requerido"
}). trim (). min ( 1 , "El nombre de la planificación no puede estar vacío" ),
periodo: z. string ({
invalid_type_error: "El año es requerido"
}),
cursoId: z. number ({
required_error: "Debes elegir un curso"
}),
asignaturas: z. array (z. string ()). min ( 1 , {
message: "Debe seleccionar al menos una asignatura"
}). default ([])
}))
const form = useForm ({
validationSchema: formSchema,
initialValues: isEdit.value ? {
titulo: props.planificacion?.titulo,
periodo: props.planificacion?.periodo,
cursoId: props.planificacion?.curso.id,
asignaturas: props.planificacion?.asignaturas. map ( a => a.id. toString ()) || []
} : {
asignaturas: []
}
})
// Async data
const { data } = await useAsyncData ( async () => {
const [ periodos , cursos ] = await Promise . all ([
$apis.establecimiento.periodoEscolar. getByEstablecimiento (establecimientoId),
form.values.periodo ? $apis.establecimiento.establecimiento. getAllCursosByEstablecimiento (
establecimientoId,
Number (form.values.periodo),
false
) : []
])
return { periodos, cursos }
})
// Computeds
const asignaturasOptions = computed (() => {
return listaAsignaturas.value?. map (( a : ICursoAsignatura ) => ({
label: a.alias || a.asignaturaSige?.nombre || a.asignaturaCurricular?.nombre || '' ,
value: `${ a . id }`
}))
})
// Functions
const cargarAsignaturas = async () : Promise < void > => {
if ( ! form.values.cursoId) return
try {
const response = await $apis.establecimiento.establecimiento. getAsignaturasByCurso (form.values.cursoId)
if (response) {
listaAsignaturas.value = response
}
} catch (error) {
useNotification (). toast ({
title: 'Error' ,
description: 'No se pudieron cargar las asignaturas' ,
variant: 'destructive'
})
}
}
const onSubmit = form. handleSubmit ( async ( values ) => {
try {
loaderRef.value = true
const data = {
... values,
establecimientoId,
estadoId: isEdit.value ? props.planificacion?.estadoId : 1
}
if (isEdit.value) {
await $apis.planificaciones.planificacion. updatePlanificacion ({
id: props.planificacion ! .id,
data
})
emit ( 'refreshDataTable' )
} else {
const nuevaPlanificacion = await $apis.planificaciones.planificacion. savePlanificacion (data)
emit ( 'nextStep' , nuevaPlanificacion)
}
useNotification (). toast ({
title: 'Todo correcto' ,
description: `Planificación ${ isEdit . value ? 'actualizada' : 'creada'} exitosamente`
})
} catch (error) {
useNotification (). toast ({
title: 'Algo ha salido mal' ,
description: `No se ha podido ${ isEdit . value ? 'actualizar' : 'crear'} la planificación` ,
variant: 'destructive'
})
} finally {
loaderRef.value = false
}
})
// Watchers
watch (() => form.values.cursoId, async () => {
await cargarAsignaturas ()
// Si estamos editando, mantener las asignaturas seleccionadas
if (isEdit.value && props.planificacion?.asignaturas) {
form. setFieldValue ( 'asignaturas' , props.planificacion.asignaturas. map ( a => a.id. toString ()))
asignaturasSeleccionadas.value = props.planificacion.asignaturas. map ( a => a.id. toString ())
} else {
form. setFieldValue ( 'asignaturas' , [])
asignaturasSeleccionadas.value = []
}
}, { immediate: true })
</ script >
< template lang = "pug" >
form .grid.gap-5.grid-cols-5 ( @ submit . prevent = " onSubmit " )
.col-span-5
FormField ( name = "titulo" v-slot = " { componentField } " )
FormItem
FormLabel Nombre
FormControl
Input (
type = "text"
placeholder = "Nombre de la planificación"
v-bind = " componentField "
)
FormMessage
.col-span-3
FormField ( name = "periodo" v-slot = " { componentField } " )
FormItem
FormLabel Año escolar
Select ( v-bind = " componentField " )
FormControl
SelectTrigger
SelectValue ( placeholder = "Seleccionar año" )
SelectContent
SelectGroup
SelectItem (
v-for = " periodo in data.periodos "
: key = " periodo.id "
: value = "`${ periodo . periodo }`"
) {{ periodo.periodo }}
FormMessage
.col-span-5
FormField ( name = "cursoId" v-slot = " { componentField } " )
FormItem
FormLabel Curso
FormControl
Select ( v-bind = " componentField " )
SelectTrigger
SelectValue ( placeholder = "Seleccionar curso" )
SelectContent
SelectGroup
SelectItem (
v-for = " curso in data.cursos "
: key = " curso.id "
: value = " curso.id "
) {{ curso.nombre || `${ curso . sige ?. descripcion } ${ curso . seccion }` }}
FormMessage
.col-span-5
FormField ( name = "asignaturas" v-slot = " { componentField } " )
FormItem
FormLabel Asignaturas
FormControl
MultiSelect (
v-model = " asignaturasSeleccionadas "
placeholder = "Elegir asignatura(s)"
: max-count = " 3 "
@ select = " ( value ) => form. setFieldValue ( 'asignaturas' , value) "
: options = " asignaturasOptions "
)
FormMessage
.col-span-5
Button .w-full.flex.gap-2 ( type = "submit" )
Loader .w-4.h-4.animate-spin ( v-if = " loaderRef " )
span {{ isEdit ? "Actualizar" : "Guardar" }} Planificación
</ template >
// composables/useFilteredData.ts
import type { IItem } from '@redcollege/ui-nuxt-module/runtime/models'
export const useFilteredData = < T extends IItem >( items : Ref < T []>) => {
const searchTerm = ref < string >( '' )
const selectedFilter = ref < string >( 'todos' )
const removeAccents = ( str : string ) : string => {
return str. normalize ( 'NFD' )
. replace ( / [\u0300-\u036f] / g , '' )
. toLowerCase ()
}
const filteredItems = computed < T []>(() => {
if ( ! items.value) return []
let result = [ ... items.value]
// Aplicar filtro por categoría si no es 'todos'
if (selectedFilter.value !== 'todos' ) {
result = result. filter ( item => item.categoriaId. toString () === selectedFilter.value)
}
// Aplicar búsqueda por texto
if (searchTerm.value. trim ()) {
const normalized = removeAccents (searchTerm.value. trim ())
result = result. filter ( item => {
const title = removeAccents (item.titulo || '' )
const description = removeAccents (item.descripcion || '' )
return title. includes (normalized) || description. includes (normalized)
})
}
return result
})
const totalItems = computed (() => filteredItems.value. length )
return {
searchTerm,
selectedFilter,
filteredItems,
totalItems
}
}
< script setup lang = "ts" >
import type { IPlanificacion } from '@redcollege/ui-nuxt-module/runtime/models'
// Variables
const { $apis } = useNuxtApp ()
const route = useRoute ()
const establecimientoId = Number (route.params.establecimientoid)
const searchTerm = ref < string >( '' )
// Carga inicial de datos con useAsyncData
const { data : planificaciones , pending , refresh } = await useAsyncData (
'planificaciones' ,
() => $apis.planificaciones.planificacion. getPlanificacionesByEstablecimiento (establecimientoId),
{
onError : ( error ) => {
useNotification (). toast ({
title: 'Error' ,
description: 'No se pudieron cargar las planificaciones' ,
variant: 'destructive'
})
}
}
)
// Filtrado
const filteredItems = computed (() => {
if ( ! planificaciones.value) return []
if ( ! searchTerm.value. trim ()) return planificaciones.value
const normalizedSearch = searchTerm.value. toLowerCase (). normalize ( 'NFD' ). replace ( / [\u0300-\u036f] / g , '' )
return planificaciones.value. filter ( plan => {
const normalizedTitle = plan.titulo. toLowerCase (). normalize ( 'NFD' ). replace ( / [\u0300-\u036f] / g , '' )
return normalizedTitle. includes (normalizedSearch)
})
})
</ script >
< template lang = "pug" >
.container.mx-auto.py-8
.flex.justify-between.items-center.mb-6
h1 .text-2xl.font-bold Planificaciones
Button (
variant = "primary"
: to = "`/${ establecimientoId }/planificaciones/crear`"
)
Icon ( name = "tabler:plus" class = "mr-2" )
span Nueva Planificación
.mb-6
Input .w-full (
placeholder = "Buscar planificaciones..."
v-model = " searchTerm "
)
template ( # prefix )
Icon .w-5.h-5.text-muted-foreground ( name = "tabler:search" )
.grid.grid-cols-1.md : grid-cols-2.lg:grid-cols-3.gap-6
template ( v-if = " pending " )
CardSkeleton ( v-for = " n in 6 " : key = " n " )
template ( v-else-if = " filteredItems. length === 0 " )
.col-span-full.flex.flex-col.items-center.py-12
Icon .w-16.h-16.text-muted-foreground.mb-4 ( name = "tabler:file-off" )
h3 .text-xl.font-medium No se encontraron planificaciones
p .text-muted-foreground Intenta con otra búsqueda o crea una nueva planificación
CardPlanificacion (
v-for = " plan in filteredItems "
: key = " plan.id "
: planificacion = " plan "
: to = "`/${ establecimientoId }/planificaciones/${ plan . id }`"
)
</ template >
TypeScript Usar import type para importar tipos Definir interface para props Usar arrow functions Evitar type assertions innecesarios Vue/Nuxt Mantener una estructura de componente clara Usar composables para lógica reutilizable Preferir ref para primitivos y reactive para objetos Usar computed para valores derivados Pug Mantener una indentación consistente Colocar atributos en líneas separadas cuando hay más de 5 Evitar v-for con v-if en el mismo elemento Usar clases encadenadas con sintaxis de punto para Tailwind Tailwind Organizar clases en un orden lógico Aplicar enfoque mobile-first Usar class solo para clases con caracteres especiales Preferir clases semánticas sobre valores hexadecimales APIs y Datos Usar useAsyncData para carga inicial de datos Usar Promise.all para peticiones paralelas Aprovechar los estados pending/error proporcionados por useAsyncData Centralizar lógica de API en composables