Skip to Content
UNIMAST

UI y Formularios

UNIMAST ERP utiliza una biblioteca robusta de componentes UI basada en shadcn/ui, con un sistema avanzado de formularios implementado con react-hook-form, validación Zod y patrones de diseño consistentes.

🎨 Biblioteca de Componentes

Arquitectura de Componentes

UNIMAST ERP utiliza shadcn/ui, una biblioteca de componentes construida sobre Radix UI que proporciona:

  • Accesibilidad nativa con soporte para lectores de pantalla
  • Componentes headless sin estilos predefinidos
  • Integración perfecta con Tailwind CSS
  • Sistema de variantes con class-variance-authority (CVA)
  • Tema personalizable con CSS variables

Componentes Disponibles

Componentes de Formulario

  • Form - Sistema completo de formularios con react-hook-form
  • Input - Campos de entrada con estilos consistentes
  • Label - Etiquetas accesibles para campos
  • Textarea - Áreas de texto multilínea
  • Select - Selectores desplegables
  • Checkbox - Casillas de verificación
  • Radio Group - Grupos de botones de radio
  • Switch - Interruptores tipo toggle
  • Toggle - Botones de alternancia
  • Slider - Controles deslizantes

Componentes de Navegación

  • Button - Botones con múltiples variantes
  • Dialog - Ventanas modales y diálogos
  • Sheet - Paneles laterales deslizables
  • Tabs - Pestañas organizadas
  • Breadcrumb - Navegación de migas de pan
  • Command - Búsqueda y navegación por comandos
  • Dropdown Menu - Menús desplegables
  • Popover - Popups contextuales
  • Tooltip - Información emergente

Componentes de Datos

  • Table - Tablas de datos con sorting y paginación
  • Card - Contenedores de contenido
  • Badge - Etiquetas y indicadores
  • Avatar - Imágenes de perfil
  • Alert - Notificaciones y alertas
  • Skeleton - Placeholders de carga

Componentes de Layout

  • Separator - Separadores visuales
  • Scroll Area - Áreas con scroll personalizado
  • Calendar - Selector de fechas
  • Toggle Group - Grupos de botones de alternancia

Ubicación de Componentes

app/ ├── components/ │ ├── ui/ # Componentes base (shadcn/ui) │ │ ├── button.tsx # Botones con variantes │ │ ├── form.tsx # Sistema de formularios │ │ ├── input.tsx # Campos de entrada │ │ ├── dialog.tsx # Ventanas modales │ │ ├── table.tsx # Tablas de datos │ │ └── ... # Más componentes │ ├── submit-button.tsx # Botón de envío especializado │ └── confirmation-dialog.tsx # Diálogo de confirmación

🎭 Sistema de Variantes

Class Variance Authority (CVA)

Los componentes utilizan CVA para manejar múltiples variantes de manera declarativa:

// app/components/ui/button.tsx const buttonVariants = cva( "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50", { variants: { variant: { default: "bg-primary text-primary-foreground shadow hover:bg-primary/90", destructive: "bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90", outline: "border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground", secondary: "bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80", ghost: "hover:bg-accent hover:text-accent-foreground", link: "text-primary underline-offset-4 hover:underline", }, size: { default: "h-9 px-4 py-2", sm: "h-8 rounded-md px-3 text-xs", lg: "h-10 rounded-md px-8", icon: "h-9 w-9", }, }, defaultVariants: { variant: "default", size: "default", }, } );

Uso de Variantes

// Botón con variante destructiva y tamaño pequeño <Button variant="destructive" size="sm"> Eliminar </Button> // Botón con variante outline y tamaño grande <Button variant="outline" size="lg"> Cancelar </Button> // Botón con variante ghost y tamaño de icono <Button variant="ghost" size="icon"> <Settings className="h-4 w-4" /> </Button>

🎨 Tema y Personalización

Configuración de Tailwind

// app/tailwind.config.ts export default { darkMode: ['class'], theme: { extend: { colors: { border: 'hsl(var(--border))', input: 'hsl(var(--input))', ring: 'hsl(var(--ring))', background: 'hsl(var(--background))', foreground: 'hsl(var(--foreground))', primary: { DEFAULT: 'hsl(var(--primary))', foreground: 'hsl(var(--primary-foreground))' }, secondary: { DEFAULT: 'hsl(var(--secondary))', foreground: 'hsl(var(--secondary-foreground))' }, destructive: { DEFAULT: 'hsl(var(--destructive))', foreground: 'hsl(var(--destructive-foreground))' }, muted: { DEFAULT: 'hsl(var(--muted))', foreground: 'hsl(var(--muted-foreground))' }, accent: { DEFAULT: 'hsl(var(--accent))', foreground: 'hsl(var(--accent-foreground))' }, popover: { DEFAULT: 'hsl(var(--popover))', foreground: 'hsl(var(--popover-foreground))' }, card: { DEFAULT: 'hsl(var(--card))', foreground: 'hsl(var(--card-foreground))' } }, borderRadius: { lg: 'var(--radius)', md: 'calc(var(--radius) - 2px)', sm: 'calc(var(--radius) - 4px)' }, keyframes: { 'accordion-down': { from: { height: '0' }, to: { height: 'var(--radix-accordion-content-height)' } }, 'accordion-up': { from: { height: 'var(--radix-accordion-content-height)' }, to: { height: '0' } } }, animation: { 'accordion-down': 'accordion-down 0.2s ease-out', 'accordion-up': 'accordion-up 0.2s ease-out' } } }, plugins: [require('tailwindcss-animate')] };

CSS Variables del Tema

/* app/app/globals.css */ :root { --background: 0 0% 100%; --foreground: 222.2 84% 4.9%; --card: 0 0% 100%; --card-foreground: 222.2 84% 4.9%; --popover: 0 0% 100%; --popover-foreground: 222.2 84% 4.9%; --primary: 222.2 47.4% 11.2%; --primary-foreground: 210 40% 98%; --secondary: 210 40% 96%; --secondary-foreground: 222.2 47.4% 11.2%; --muted: 210 40% 96%; --muted-foreground: 215.4 16.3% 46.9%; --accent: 210 40% 96%; --accent-foreground: 222.2 47.4% 11.2%; --destructive: 0 84.2% 60.2%; --destructive-foreground: 210 40% 98%; --border: 214.3 31.8% 91.4%; --input: 214.3 31.8% 91.4%; --ring: 222.2 84% 4.9%; --radius: 0.5rem; }

📝 Sistema de Formularios

Arquitectura de Formularios

UNIMAST ERP utiliza un sistema de formularios basado en react-hook-form con las siguientes características:

  • Gestión de estado centralizada y eficiente
  • Validación con Zod para type safety completo
  • Componentes de formulario especializados
  • Manejo de errores consistente
  • Integración con notificaciones para feedback

Componentes de Formulario Base

Form Component

// app/components/ui/form.tsx import { Controller, type ControllerProps, type FieldPath, type FieldValues, FormProvider, useFormContext, } from "react-hook-form" const Form = FormProvider const FormField = < TFieldValues extends FieldValues = FieldValues, TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues> >({ ...props }: ControllerProps<TFieldValues, TName>) => { return ( <FormFieldContext.Provider value={{ name: props.name }}> <Controller {...props} /> </FormFieldContext.Provider> ) } const FormItem = React.forwardRef< HTMLDivElement, React.HTMLAttributes<HTMLDivElement> >(({ className, ...props }, ref) => { const id = React.useId() return ( <FormItemContext.Provider value={{ id }}> <div ref={ref} className={cn("space-y-2", className)} {...props} /> </FormItemContext.Provider> ) }) const FormLabel = React.forwardRef< React.ElementRef<typeof LabelPrimitive.Root>, React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> >(({ className, ...props }, ref) => { const { error, formItemId } = useFormField() return ( <Label ref={ref} className={cn(error && "text-destructive", className)} htmlFor={formItemId} {...props} /> ) }) const FormControl = React.forwardRef< React.ElementRef<typeof Slot>, React.ComponentPropsWithoutRef<typeof Slot> >(({ ...props }, ref) => { const { error, formItemId, formDescriptionId, formMessageId } = useFormField() return ( <Slot ref={ref} id={formItemId} aria-describedby={ !error ? `${formDescriptionId}` : `${formDescriptionId} ${formMessageId}` } aria-invalid={!!error} {...props} /> ) }) const FormMessage = React.forwardRef< HTMLParagraphElement, React.HTMLAttributes<HTMLParagraphElement> >(({ className, children, ...props }, ref) => { const { error, formMessageId } = useFormField() const body = error ? String(error?.message) : children if (!body) { return null } return ( <p ref={ref} id={formMessageId} className={cn("text-sm font-medium text-destructive", className)} {...props} > {body} </p> ) })

Input Component

// app/components/ui/input.tsx const Input = React.forwardRef<HTMLInputElement, React.ComponentProps<"input">>( ({ className, type, ...props }, ref) => { return ( <input type={type} className={cn( "flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-base shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 md:text-sm", className )} ref={ref} {...props} /> ) } )

Componentes Especializados del Proyecto

SubmitButton

// app/components/submit-button.tsx export function SubmitButton({ children, className, variant = 'default', disabled = false }: { children: ReactNode; className?: string; variant?: 'default' | 'destructive' | 'outline' | 'secondary'; disabled?: boolean; }) { const form = useFormContext(); const { isSubmitting } = form.formState; return ( <Button disabled={disabled || isSubmitting} type="submit" variant={variant} className={cn('flex items-center justify-center relative', className)} > {isSubmitting && <Loader2 className="mr-2 h-4 w-4 animate-spin" />} {children} </Button> ); }

🔧 Implementación de Formularios

Patrón Básico de Formulario

import { useForm } from 'react-hook-form'; import { zodResolver } from '@hookform/resolvers/zod'; import { z } from 'zod'; // 1. Definir esquema de validación const formSchema = z.object({ name: z.string().min(1, 'El nombre es obligatorio'), email: z.string().email('Correo no válido'), roleId: z.string().nonempty('El rol es obligatorio') }); // 2. Crear formulario con react-hook-form const form = useForm<z.infer<typeof formSchema>>({ resolver: zodResolver(formSchema), defaultValues: { name: '', email: '', roleId: '' } }); // 3. Renderizar formulario <Form {...form}> <form onSubmit={form.handleSubmit(handleSubmit)} className="space-y-4"> <FormField control={form.control} name="name" render={({ field }) => ( <FormItem> <FormLabel>Nombre</FormLabel> <FormControl> <Input placeholder="Ingrese nombre" {...field} /> </FormControl> <FormMessage /> </FormItem> )} /> {/* Más campos... */} </form> </Form>

Validación con Zod

// Esquemas de validación comunes const userSchema = z.object({ name: z.string() .min(1, 'El nombre es obligatorio') .min(2, 'El nombre debe tener al menos 2 caracteres') .max(50, 'El nombre no puede exceder 50 caracteres'), email: z.string() .email('Correo electrónico no válido') .min(1, 'El correo es obligatorio'), password: z.string() .min(6, 'La contraseña debe tener al menos 6 caracteres') .regex(/[A-Z]/, 'Debe contener al menos una mayúscula') .regex(/[0-9]/, 'Debe contener al menos un número') .optional(), roleId: z.string() .nonempty('El rol es obligatorio') .cuid('ID de rol inválido'), isActive: z.boolean() .default(true) }); // Validación condicional const conditionalSchema = z.object({ type: z.enum(['individual', 'company']), companyName: z.string().optional(), taxId: z.string().optional() }).refine((data) => { if (data.type === 'company') { return data.companyName && data.taxId; } return true; }, { message: 'Para empresas, el nombre y RNC son obligatorios', path: ['companyName'] });

📋 Patrones de Formularios

Formulario Modal Completo

// app/app/(dashboard)/settings/users/user-modal.tsx export function UserModal({ user, roles }: UserModalProps) { const form = useForm<z.infer<typeof formSchema>>({ resolver: zodResolver(formSchema), defaultValues: { name: '', email: '', password: '' } }); const handleSubmit = async (values: z.infer<typeof formSchema>) => { try { if (isEditing) { await updateUser(user.id, { name: values.name, email: values.email, roleId: values.roleId }); } else { await createUser({ name: values.name, email: values.email, password: values.password ?? null, roleId: values.roleId, image: '' }); } onClose(); toast.success('Usuario guardado exitosamente'); } catch (error) { toast.error('Error al guardar el usuario. Inténtalo de nuevo.'); } }; return ( <Dialog open={isOpen} onOpenChange={(open) => !open && onClose()}> <DialogContent className="sm:max-w-[425px]"> <DialogHeader> <DialogTitle> {isEditing ? 'Editar Usuario' : 'Agregar Nuevo Usuario'} </DialogTitle> </DialogHeader> <Form {...form}> <form onSubmit={form.handleSubmit(handleSubmit)} className="space-y-4 py-4"> <FormField control={form.control} name="name" render={({ field }) => ( <FormItem> <FormLabel>Nombre</FormLabel> <FormControl> <Input placeholder="Ingrese nombre" {...field} /> </FormControl> <FormMessage /> </FormItem> )} /> <FormField control={form.control} name="email" render={({ field }) => ( <FormItem> <FormLabel>Email</FormLabel> <FormControl> <Input placeholder="Ingrese email" type="email" {...field} /> </FormControl> <FormMessage /> </FormItem> )} /> {!isEditing && ( <FormField control={form.control} name="password" render={({ field }) => ( <FormItem> <FormLabel>Contraseña</FormLabel> <FormControl> <Input placeholder="Ingrese contraseña" type="password" {...field} /> </FormControl> <FormMessage /> </FormItem> )} /> )} <FormField control={form.control} name="roleId" render={({ field }) => ( <FormItem> <FormLabel>Rol</FormLabel> <Select onValueChange={field.onChange} defaultValue={field.value}> <FormControl> <SelectTrigger className="w-[180px]"> <SelectValue placeholder="Selecciona un rol" /> </SelectTrigger> </FormControl> <SelectContent> {roles.map((role) => ( <SelectItem key={role.id} value={role.id}> {role.name} </SelectItem> ))} </SelectContent> </Select> <FormMessage /> </FormItem> )} /> <DialogFooter> <Button type="button" variant="outline" onClick={onClose}> Cancelar </Button> <SubmitButton> {isEditing ? 'Actualizar' : 'Crear'} </SubmitButton> </DialogFooter> </form> </Form> </DialogContent> </Dialog> ); }

Formulario con Campos Condicionales

const PaymentForm = () => { const form = useForm<PaymentFormData>({ resolver: zodResolver(paymentSchema), defaultValues: { paymentMethod: 'cash', amount: 0, reference: '', customerId: '' } }); const paymentMethod = form.watch('paymentMethod'); return ( <Form {...form}> <form onSubmit={form.handleSubmit(handleSubmit)} className="space-y-4"> <FormField control={form.control} name="paymentMethod" render={({ field }) => ( <FormItem> <FormLabel>Método de Pago</FormLabel> <Select onValueChange={field.onChange} defaultValue={field.value}> <FormControl> <SelectTrigger> <SelectValue placeholder="Selecciona método de pago" /> </SelectTrigger> </FormControl> <SelectContent> <SelectItem value="cash">Efectivo</SelectItem> <SelectItem value="card">Tarjeta</SelectItem> <SelectItem value="transfer">Transferencia</SelectItem> </SelectContent> </Select> <FormMessage /> </FormItem> )} /> {/* Campos condicionales basados en método de pago */} {paymentMethod === 'card' && ( <FormField control={form.control} name="cardNumber" render={({ field }) => ( <FormItem> <FormLabel>Número de Tarjeta</FormLabel> <FormControl> <Input placeholder="1234 5678 9012 3456" {...field} /> </FormControl> <FormMessage /> </FormItem> )} /> )} {paymentMethod === 'transfer' && ( <FormField control={form.control} name="reference" render={({ field }) => ( <FormItem> <FormLabel>Número de Referencia</FormLabel> <FormControl> <Input placeholder="REF-123456789" {...field} /> </FormControl> <FormMessage /> </FormItem> )} /> )} <SubmitButton>Procesar Pago</SubmitButton> </form> </Form> ); };

🔔 Sistema de Notificaciones

Toaster Global con Sonner

// app/app/layout.tsx import { Toaster } from '@/components/ui/sonner'; export default function RootLayout({ children }) { return ( <html lang="en"> <body> {children} <Toaster richColors={false} toastOptions={{ classNames: { title: 'text-sm font-medium', description: 'text-muted-foreground text-xs' } }} theme="light" /> </body> </html> ); }

Uso de Notificaciones en Formularios

import { toast } from 'sonner'; const handleSubmit = async (values: FormData) => { try { // Procesar formulario await submitForm(values); // Notificación de éxito toast.success('Formulario enviado exitosamente'); // Resetear formulario form.reset(); } catch (error) { // Notificación de error toast.error('Error al enviar el formulario. Inténtalo de nuevo.'); // Log del error para debugging console.error('Form submission error:', error); } }; // Diferentes tipos de notificaciones toast.success('Operación exitosa'); toast.error('Algo salió mal'); toast.warning('Advertencia importante'); toast.info('Información del sistema'); toast.loading('Procesando...', { duration: 2000 });

🚀 Mejores Prácticas

1. Estructura de Formularios

// ✅ CORRECTO: Separar lógica de UI const UserForm = () => { const form = useForm<UserFormData>({ resolver: zodResolver(userSchema), defaultValues: getDefaultValues() }); const handleSubmit = useCallback(async (values: UserFormData) => { // Lógica de envío separada }, []); return ( <Form {...form}> <form onSubmit={form.handleSubmit(handleSubmit)}> {/* Campos del formulario */} </form> </Form> ); }; // ❌ INCORRECTO: Lógica mezclada con JSX const UserForm = () => { return ( <form onSubmit={(e) => { e.preventDefault(); // Lógica compleja aquí const formData = new FormData(e.target); // Más lógica... }}> {/* Campos */} </form> ); };

2. Validación y Manejo de Errores

// ✅ CORRECTO: Validación centralizada con Zod const formSchema = z.object({ email: z.string() .email('Correo electrónico no válido') .min(1, 'El correo es obligatorio'), password: z.string() .min(6, 'Mínimo 6 caracteres') .regex(/[A-Z]/, 'Debe contener mayúscula') }); // ✅ CORRECTO: Manejo de errores consistente const handleSubmit = async (values: FormData) => { try { await submitForm(values); toast.success('Éxito'); } catch (error) { if (error instanceof ValidationError) { toast.error('Datos inválidos'); } else if (error instanceof NetworkError) { toast.error('Error de conexión'); } else { toast.error('Error inesperado'); } } };

3. Accesibilidad

// ✅ CORRECTO: Labels y aria-attributes <FormField control={form.control} name="email" render={({ field }) => ( <FormItem> <FormLabel htmlFor="email-field">Correo Electrónico</FormLabel> <FormControl> <Input id="email-field" type="email" placeholder="usuario@ejemplo.com" aria-describedby="email-help" {...field} /> </FormControl> <FormMessage /> <p id="email-help" className="text-sm text-muted-foreground"> Ingresa tu correo electrónico principal </p> </FormItem> )} /> // ✅ CORRECTO: Estados de focus y disabled <Button disabled={isSubmitting} aria-busy={isSubmitting} className="focus:ring-2 focus:ring-offset-2 focus:ring-primary" > {isSubmitting ? 'Enviando...' : 'Enviar'} </Button>

4. Performance y Optimización

// ✅ CORRECTO: Memoización de callbacks const handleSubmit = useCallback(async (values: FormData) => { // Lógica de envío }, [dependencies]); // ✅ CORRECTO: Debounce para campos de búsqueda const debouncedSearch = useMemo( () => debounce((query: string) => { performSearch(query); }, 300), [] ); // ✅ CORRECTO: Lazy loading de componentes pesados const HeavyComponent = lazy(() => import('./HeavyComponent')); const FormWithHeavyComponent = () => { const [showHeavy, setShowHeavy] = useState(false); return ( <div> <Button onClick={() => setShowHeavy(true)}> Mostrar Componente Pesado </Button> {showHeavy && ( <Suspense fallback={<Skeleton className="h-32" />}> <HeavyComponent /> </Suspense> )} </div> ); };

🔧 Troubleshooting

Problemas Comunes

1. Formulario no se envía

// Verificar que el formulario esté correctamente configurado const form = useForm({ resolver: zodResolver(schema), defaultValues: initialValues }); // Debug del estado del formulario console.log('Form state:', form.formState); console.log('Form values:', form.getValues()); console.log('Form errors:', form.formState.errors);

2. Validación no funciona

// Verificar que el resolver esté correctamente configurado import { zodResolver } from '@hookform/resolvers/zod'; const form = useForm({ resolver: zodResolver(formSchema), // Asegúrate de que esto esté presente defaultValues: {} }); // Verificar que el esquema de Zod sea válido console.log('Schema:', formSchema);

3. Campos no se actualizan

// Verificar que los campos estén correctamente registrados <FormField control={form.control} name="fieldName" // El nombre debe coincidir con el esquema render={({ field }) => ( <Input {...field} // Esto es crucial para el binding onChange={(e) => { field.onChange(e.target.value); // Lógica adicional si es necesaria }} /> )} />

4. Errores de TypeScript

// Asegúrate de que los tipos coincidan type FormData = z.infer<typeof formSchema>; const form = useForm<FormData>({ resolver: zodResolver(formSchema), defaultValues: {} as FormData }); // O usar el tipo directamente const form = useForm<z.infer<typeof formSchema>>({ resolver: zodResolver(formSchema), defaultValues: {} });

Debug de Formularios

// Agregar logging para debugging useEffect(() => { const subscription = form.watch((value, { name, type }) => { console.log('Form changed:', { value, name, type }); }); return () => subscription.unsubscribe(); }, [form]); // Debug del estado de envío useEffect(() => { console.log('Form submission state:', { isSubmitting: form.formState.isSubmitting, isDirty: form.formState.isDirty, isValid: form.formState.isValid, errors: form.formState.errors }); }, [form.formState]);

¿Necesitas más detalles sobre algún aspecto específico? Revisa la documentación de autenticación para entender cómo se integran los formularios con el sistema de permisos, o la documentación de APIs para aprender sobre el envío de datos.