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.