Estandares de UI — Plataforma Imagy
Principios
- Un solo panel: Una aplicacion React para todos los modulos y tenants
- Feature-sliced: Organizacion por dominio de negocio, no por tipo de archivo
- Type-safe: TypeScript estricto, Zod para validacion, tipos inferidos
- Server state vs Client state: TanStack Query para datos del servidor, Zustand para UI
- Accesibilidad: WCAG 2.1 AA como minimo
- Responsive: Mobile-first, funcional en todas las resoluciones
Stack Tecnologico
| Libreria | Version | Proposito |
|---|---|---|
| React | 19.x | Framework UI |
| TypeScript | 6.x | Tipado estatico |
| Vite | 8.x | Bundler y dev server |
| React Router | 7.x | Routing |
| TanStack Query | 5.x | Server state (data fetching, cache, mutations) |
| Zustand | 5.x | Client state (UI state, filtros, seleccion) |
| React Hook Form | 7.x | Formularios complejos (wizard, multi-paso) |
| Zod | 3.x | Validacion de schemas |
| DevExtreme | 25.x | Componentes UI (DataGrid, Forms, Charts, Scheduler) |
| Tailwind CSS | 4.x | Estilos utilitarios (complementa DevExtreme) |
| i18next | 26.x | Internacionalizacion |
| Vitest | 4.x | Testing |
| Testing Library | 16.x | Testing de componentes |
| fast-check | 4.x | Property-based testing |
| MSW | 2.x | Mock Service Worker (testing de API) |
Estructura del Proyecto
El frontend sigue Clean Architecture adaptada a React, con separacion clara entre capas:
src/
├── main.tsx # Entry point
├── index.css # Tailwind + DevExtreme theme
│
├── domain/ # Capa de dominio (sin dependencias externas)
│ ├── entities/ # Entidades de negocio (Tenant, User, etc.)
│ ├── value-objects/ # Value objects (ThemeConfig, SupportedLanguage)
│ └── ports/ # Interfaces/contratos (IApiPort, IThemePort, IStoragePort)
│
├── application/ # Casos de uso y logica de aplicacion
│ └── hooks/ # Hooks de aplicacion (useAuth, useTheme, useTranslation)
│
├── infrastructure/ # Implementaciones concretas
│ ├── api/ # Cliente HTTP (apiClient, queryClient, ApiAdapter)
│ ├── auth/ # BFF auth adapter (fetchSession, redirectToLogin/Logout)
│ ├── tenant/ # TenantResolver (extrae cname del hostname)
│ ├── stores/ # Zustand stores (authStore, themeStore, layoutStore)
│ ├── theme/ # ThemeAdapter (carga tema del tenant)
│ ├── storage/ # SessionStorageAdapter
│ ├── i18n/ # Configuracion i18next
│ ├── devextreme/ # Configuracion y tema DevExtreme
│ └── mocks/ # Datos mock para desarrollo
│
├── presentation/ # Capa de presentacion (UI)
│ ├── layout/ # Shell, Sidebar, TopBar, Breadcrumb, MobileDrawer
│ │ └── navigationConfig.ts # Configuracion de modulos y submodulos
│ ├── components/ # Componentes reutilizables
│ │ ├── ui/ # Primitivos (Button, Input, Checkbox, etc.)
│ │ ├── form/ # Form wrappers DevExtreme (FormTextBox, FormSelectBox, etc.)
│ │ ├── common/ # ProtectedRoute, ErrorBoundary
│ │ ├── shared/ # Componentes compartidos entre vistas
│ │ ├── icons/ # Iconos SVG
│ │ └── image/ # Componentes de imagen
│ ├── views/ # Vistas por modulo
│ │ ├── admin/ # Vistas de administracion
│ │ │ └── plataformas/ # Admin de plataforma (usuarios, roles, etc.)
│ │ ├── app/ # Vistas de aplicacion (operador)
│ │ └── profile/ # Perfil del usuario
│ └── stores/ # Stores especificos de presentacion
│
├── router/ # Configuracion de React Router
│ └── index.tsx # Rutas con lazy loading
│
├── assets/ # Assets estaticos (logos, imagenes)
│
└── test/ # Configuracion de testing
├── setup.ts
└── smoke/ # Smoke testsReglas de Dependencia entre Capas
- Domain: No importa nada de las otras capas. Solo tipos puros y contratos.
- Application: Solo importa de Domain. Hooks de logica de negocio.
- Infrastructure: Implementa los ports de Domain. Contiene adapters concretos.
- Presentation: Puede importar de Application e Infrastructure. Contiene toda la UI.
Modulos en la Navegacion
Los modulos se configuran en presentation/layout/navigationConfig.ts:
typescript
export interface ModuleConfig {
id: string;
icon: ComponentType<{ className?: string }>;
labelKey: string; // Key de i18n
route: string;
submodules?: SubmoduleConfig[];
}
// Cada modulo nuevo se agrega aqui
export const navigationConfig: ModuleConfig[] = [
{ id: 'dashboard', icon: IconDashboard, labelKey: 'nav.dashboard', route: '/dashboard' },
{ id: 'lending', icon: IconLending, labelKey: 'nav.lending', route: '/lending',
submodules: [
{ id: 'productos', labelKey: 'nav.sub.productos', route: '/lending/productos' },
{ id: 'solicitudes', labelKey: 'nav.sub.solicitudes', route: '/lending/solicitudes' },
{ id: 'cartera', labelKey: 'nav.sub.cartera', route: '/lending/cartera' },
],
},
{ id: 'flows', icon: IconFlows, labelKey: 'nav.flows', route: '/flows' },
{ id: 'sign', icon: IconSign, labelKey: 'nav.sign', route: '/sign' },
{ id: 'subjects', icon: IconSubjects, labelKey: 'nav.subjects', route: '/subjects' },
// ...
];DevExtreme - Uso y Convenciones
Componentes DevExtreme que usamos
| Componente | Uso principal |
|---|---|
DataGrid | Tablas con paginacion, filtros, sorting, export |
Form | Formularios con validacion integrada |
TextBox, SelectBox, NumberBox | Inputs con formato |
Popup, LoadPanel | Modales y loading |
Chart, PieChart | Graficos y dashboards |
Scheduler | Calendarios (si se necesita) |
TreeView | Navegacion jerarquica |
Wrappers de Formulario
Cada input de DevExtreme tiene un wrapper que estandariza el uso:
typescript
// presentation/components/form/FormTextBox.tsx
interface FormTextBoxProps {
label: string;
value: string;
onValueChanged: (value: string) => void;
isRequired?: boolean;
validationRules?: ValidationRule[];
placeholder?: string;
}
export const FormTextBox: React.FC<FormTextBoxProps> = ({
label, value, onValueChanged, isRequired, validationRules, placeholder
}) => (
<div className="form-field">
<label className="form-label">{label}{isRequired && ' *'}</label>
<TextBox
value={value}
onValueChanged={(e) => onValueChanged(e.value)}
placeholder={placeholder}
>
{validationRules?.map((rule, i) => (
<Validator key={i}><RequiredRule message={rule.message} /></Validator>
))}
</TextBox>
</div>
);Cuando usar DevExtreme vs React Hook Form + Zod
| Escenario | Herramienta | Razon |
|---|---|---|
| DataGrid con edicion inline | DevExtreme Form | Integrado con el grid |
| Formularios CRUD simples | DevExtreme Form | Rapido, validacion built-in |
| Wizard multi-paso (credito) | React Hook Form + Zod | Mejor control de estado entre pasos |
| Formularios con logica compleja | React Hook Form + Zod | Mejor composicion y testing |
| Formularios en apps publicas | React Hook Form + Zod | Menor bundle size (sin DevExtreme) |
Tema DevExtreme
El tema se configura en infrastructure/devextreme/devextremeConfig.ts y se sincroniza con el tema del tenant (light/dark).
Data Fetching (TanStack Query)
Queries (lectura)
typescript
// features/lending/hooks/useProducts.ts
import { useQuery } from '@tanstack/react-query';
import { api } from '@/lib/api';
import type { PagedResponse, CreditProductDto } from '../types/lending.types';
export function useProducts(filters?: ProductFilters) {
return useQuery({
queryKey: ['credit-products', filters],
queryFn: () => api.get<PagedResponse<CreditProductDto>>(
'/api/v1/credit-products',
{ params: filters }
),
staleTime: 30_000, // 30s antes de refetch
});
}
export function useProduct(id: string) {
return useQuery({
queryKey: ['credit-products', id],
queryFn: () => api.get<CreditProductDto>(`/api/v1/credit-products/${id}`),
enabled: !!id,
});
}Mutations (escritura)
typescript
// features/lending/hooks/useCreateProduct.ts
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { api } from '@/lib/api';
import { v4 as uuid } from 'uuid';
import { toast } from '@/components/feedback/Toast';
export function useCreateProduct() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (data: CreateProductDto) =>
api.post('/api/v1/credit-products', data, {
headers: {
'Idempotency-Key': uuid(), // Generado por el cliente
},
}),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['credit-products'] });
toast.success('Producto creado exitosamente');
},
onError: (error: ApiError) => {
if (error.code === 'PRECONDITION_FAILED') {
toast.error('El recurso fue modificado. Recarga e intenta de nuevo.');
} else {
toast.error(error.message);
}
},
});
}Mutations con ETags
typescript
// features/lending/hooks/useUpdateProduct.ts
export function useUpdateProduct() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ id, data, etag }: UpdateProductParams) =>
api.patch(`/api/v1/credit-products/${id}`, data, {
headers: {
'Content-Type': 'application/merge-patch+json',
'If-Match': etag, // ETag del GET previo
},
}),
onSuccess: (_, variables) => {
queryClient.invalidateQueries({
queryKey: ['credit-products', variables.id]
});
toast.success('Producto actualizado');
},
onError: (error: ApiError) => {
if (error.code === 'PRECONDITION_FAILED') {
toast.error('Otro usuario modifico este recurso. Recarga la pagina.');
// Invalidar cache para forzar refetch
queryClient.invalidateQueries({ queryKey: ['credit-products'] });
}
},
});
}Formularios (React Hook Form + Zod)
Schema de validacion
typescript
// features/lending/schemas/product.schema.ts
import { z } from 'zod';
export const createProductSchema = z.object({
code: z.string()
.min(1, 'Codigo es requerido')
.max(50)
.regex(/^[a-z0-9-]+$/, 'Solo minusculas, numeros y guiones'),
name: z.string().min(1, 'Nombre es requerido').max(200),
interestRate: z.number()
.min(0, 'Debe ser mayor o igual a 0')
.max(1, 'Debe ser menor o igual a 1'),
minAmount: z.number().positive('Debe ser mayor a 0'),
maxAmount: z.number().positive('Debe ser mayor a 0'),
minTermDays: z.number().int().positive(),
maxTermDays: z.number().int().positive(),
}).refine(data => data.maxAmount > data.minAmount, {
message: 'Monto maximo debe ser mayor al minimo',
path: ['maxAmount'],
}).refine(data => data.maxTermDays > data.minTermDays, {
message: 'Plazo maximo debe ser mayor al minimo',
path: ['maxTermDays'],
});
export type CreateProductFormData = z.infer<typeof createProductSchema>;Componente de formulario
typescript
// features/lending/components/ProductForm.tsx
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { createProductSchema, type CreateProductFormData } from '../schemas/product.schema';
import { FormField } from '@/components/form/FormField';
import { Input } from '@/components/ui/Input';
import { Button } from '@/components/ui/Button';
interface ProductFormProps {
onSubmit: (data: CreateProductFormData) => void;
isLoading?: boolean;
defaultValues?: Partial<CreateProductFormData>;
}
export const ProductForm: React.FC<ProductFormProps> = ({
onSubmit,
isLoading,
defaultValues,
}) => {
const {
register,
handleSubmit,
formState: { errors },
} = useForm<CreateProductFormData>({
resolver: zodResolver(createProductSchema),
defaultValues,
mode: 'onBlur',
});
return (
<form onSubmit={handleSubmit(onSubmit)} className="space-y-6">
<FormField label="Codigo" error={errors.code?.message} required>
<Input {...register('code')} placeholder="microcredito-30d" />
</FormField>
<FormField label="Nombre" error={errors.name?.message} required>
<Input {...register('name')} placeholder="Microcredito 30 dias" />
</FormField>
<FormField label="Tasa de interes" error={errors.interestRate?.message} required>
<Input
type="number"
step="0.001"
{...register('interestRate', { valueAsNumber: true })}
/>
</FormField>
<Button type="submit" loading={isLoading}>
Crear Producto
</Button>
</form>
);
};State Management
Regla de oro
| Tipo de estado | Herramienta | Ejemplo |
|---|---|---|
| Server state (datos de API) | TanStack Query | Lista de productos, detalle de credito |
| Form state (inputs) | React Hook Form | Campos del formulario activo |
| UI state (local) | useState | Modal abierto/cerrado, tab activo |
| UI state (compartido) | Zustand | Sidebar collapsed, filtros globales, tema |
Nunca usar Zustand para datos del servidor
typescript
// INCORRECTO - no cachear datos de API en Zustand
const useProductStore = create((set) => ({
products: [],
fetchProducts: async () => {
const data = await api.get('/products');
set({ products: data }); // NO - usar TanStack Query
},
}));
// CORRECTO - TanStack Query maneja cache, refetch, stale
const { data: products } = useProducts(filters);API Client
typescript
// lib/api.ts
import axios, { type AxiosError } from 'axios';
import type { ApiErrorResponse } from '@/types/api';
export const api = axios.create({
baseURL: import.meta.env.VITE_API_URL,
withCredentials: true, // Cookie de sesion
});
// Interceptor: extraer data del envelope
api.interceptors.response.use(
(response) => response.data?.data ?? response.data,
(error: AxiosError<ApiErrorResponse>) => {
const apiError = error.response?.data?.error;
if (error.response?.status === 401) {
// Redirigir a login
const tenantCode = getTenantFromUrl();
window.location.href = `${import.meta.env.VITE_API_URL}/api/v1/auth/login?cid=${tenantCode}`;
return Promise.reject(apiError);
}
return Promise.reject(apiError ?? {
code: 'NETWORK_ERROR',
message: 'Error de conexion',
});
}
);Routing y Code Splitting
typescript
// app/router.tsx
import { createBrowserRouter } from 'react-router-dom';
import { lazy } from 'react';
import { AuthGuard } from './guards/AuthGuard';
import { RoleGuard } from './guards/RoleGuard';
import { Shell } from '@/components/layout/Shell';
// Lazy loading por feature
const LendingRoutes = lazy(() => import('@/features/lending/routes'));
const FlowRoutes = lazy(() => import('@/features/flows/routes'));
const SignRoutes = lazy(() => import('@/features/sign/routes'));
const SubjectRoutes = lazy(() => import('@/features/subjects/routes'));
const SettingsRoutes = lazy(() => import('@/features/settings/routes'));
export const router = createBrowserRouter([
{
element: <AuthGuard />,
children: [
{
element: <Shell />,
children: [
{ path: '/', element: <Dashboard /> },
{ path: '/lending/*', element: <LendingRoutes /> },
{ path: '/flows/*', element: <FlowRoutes /> },
{ path: '/sign/*', element: <SignRoutes /> },
{ path: '/subjects/*', element: <SubjectRoutes /> },
{
path: '/settings/*',
element: <RoleGuard roles={['tenant_admin', 'platform_admin']}><SettingsRoutes /></RoleGuard>,
},
],
},
],
},
]);Permisos en UI
typescript
// hooks/usePermissions.ts
export function usePermissions() {
const { data: user } = useCurrentUser();
const can = (action: string, resource: string): boolean => {
if (!user) return false;
// Platform admin puede todo
if (user.roles.includes('platform_admin')) return true;
// Verificar modulos y permisos del usuario
const module = user.modules?.find(m => m.module === resource);
return module?.permissions.includes(action) ?? false;
};
const hasRole = (...roles: string[]): boolean => {
return roles.some(r => user?.roles.includes(r));
};
return { can, hasRole, user };
}
// Uso en componentes
const { can } = usePermissions();
return (
<div>
{can('create', 'lending') && <Button>Crear Producto</Button>}
{can('read', 'lending') && <ProductTable />}
</div>
);Manejo de Errores
Error Boundary por Feature
typescript
// components/feedback/ErrorBoundary.tsx
export const FeatureErrorBoundary: React.FC<{ children: React.ReactNode }> = ({
children,
}) => (
<ErrorBoundary
fallback={({ error, resetErrorBoundary }) => (
<div className="p-8 text-center">
<h2 className="text-lg font-medium">Algo salio mal</h2>
<p className="text-muted mt-2">{error.message}</p>
<Button onClick={resetErrorBoundary} className="mt-4">
Reintentar
</Button>
</div>
)}
>
{children}
</ErrorBoundary>
);Manejo de 412 (Concurrencia)
typescript
// Patron para manejar conflictos de concurrencia en la UI
function ProductEditPage() {
const { id } = useParams();
const { data: product, dataUpdatedAt } = useProduct(id!);
const updateProduct = useUpdateProduct();
const handleSubmit = (data: UpdateProductDto) => {
updateProduct.mutate({
id: id!,
data,
etag: product!.rowVersion.toString(), // ETag del ultimo GET
});
};
// Si hay error 412, mostrar dialogo de conflicto
if (updateProduct.error?.code === 'PRECONDITION_FAILED') {
return <ConflictDialog onReload={() => window.location.reload()} />;
}
return <ProductForm defaultValues={product} onSubmit={handleSubmit} />;
}Convenciones de Naming
| Elemento | Convencion | Ejemplo |
|---|---|---|
| Componentes | PascalCase | ProductCard.tsx |
| Hooks | camelCase con use | useProducts.ts |
| Schemas | camelCase + Schema | createProductSchema |
| Types | PascalCase | CreditProductDto |
| Stores | camelCase con use + Store | useLayoutStore.ts |
| Utils | camelCase | formatCurrency.ts |
| Constantes | UPPER_SNAKE_CASE | MAX_PAGE_SIZE |
| Carpetas | kebab-case | data-display/ |
| Archivos componente | PascalCase | ProductForm.tsx |
| Archivos no-componente | camelCase | useProducts.ts |
Testing
Que testear
| Tipo | Que | Herramienta |
|---|---|---|
| Unit | Schemas Zod, utils, calculos | Vitest |
| Component | Componentes con logica | Vitest + Testing Library |
| Integration | Flujos completos (form submit, navigation) | Vitest + Testing Library + MSW |
| E2E | Flujos criticos (login, crear credito) | Playwright |
Ejemplo de test
typescript
// features/lending/hooks/useProducts.test.ts
import { renderHook, waitFor } from '@testing-library/react';
import { useProducts } from './useProducts';
import { createWrapper } from '@/test/utils';
describe('useProducts', () => {
it('fetches products for current tenant', async () => {
const { result } = renderHook(() => useProducts(), {
wrapper: createWrapper(),
});
await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(result.current.data?.data).toHaveLength(2);
});
});Accesibilidad
Reglas obligatorias
- Todos los inputs tienen
labelasociado (viahtmlForoaria-label) - Todos los botones tienen texto descriptivo (no solo iconos)
- Contraste minimo 4.5:1 para texto normal, 3:1 para texto grande
- Navegacion completa por teclado (Tab, Enter, Escape)
- Focus visible en todos los elementos interactivos
aria-livepara notificaciones dinamicas (toasts)
DevExtreme y Accesibilidad
DevExtreme incluye soporte ARIA built-in para sus componentes (DataGrid, Form, Popup, etc.). No requiere configuracion adicional para:
- Keyboard navigation en grids y forms
- Screen reader support
- Focus management en popups y modales
- ARIA roles en componentes complejos
Para componentes custom (no DevExtreme), asegurar accesibilidad manualmente.
Performance
Reglas
- Lazy loading de features (React.lazy + Suspense)
- Imagenes con
loading="lazy"y dimensiones explicitas - Memoizacion solo cuando hay re-renders medibles (no prematura)
- TanStack Query con
staleTimeapropiado (no refetch innecesario) - Virtualizacion para listas largas (TanStack Virtual)
- Bundle analysis periodico (
vite-bundle-visualizer)