Skip to content

Estandares de UI — Plataforma Imagy

Principios

  1. Un solo panel: Una aplicacion React para todos los modulos y tenants
  2. Feature-sliced: Organizacion por dominio de negocio, no por tipo de archivo
  3. Type-safe: TypeScript estricto, Zod para validacion, tipos inferidos
  4. Server state vs Client state: TanStack Query para datos del servidor, Zustand para UI
  5. Accesibilidad: WCAG 2.1 AA como minimo
  6. Responsive: Mobile-first, funcional en todas las resoluciones

Stack Tecnologico

LibreriaVersionProposito
React19.xFramework UI
TypeScript6.xTipado estatico
Vite8.xBundler y dev server
React Router7.xRouting
TanStack Query5.xServer state (data fetching, cache, mutations)
Zustand5.xClient state (UI state, filtros, seleccion)
React Hook Form7.xFormularios complejos (wizard, multi-paso)
Zod3.xValidacion de schemas
DevExtreme25.xComponentes UI (DataGrid, Forms, Charts, Scheduler)
Tailwind CSS4.xEstilos utilitarios (complementa DevExtreme)
i18next26.xInternacionalizacion
Vitest4.xTesting
Testing Library16.xTesting de componentes
fast-check4.xProperty-based testing
MSW2.xMock 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 tests

Reglas 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

ComponenteUso principal
DataGridTablas con paginacion, filtros, sorting, export
FormFormularios con validacion integrada
TextBox, SelectBox, NumberBoxInputs con formato
Popup, LoadPanelModales y loading
Chart, PieChartGraficos y dashboards
SchedulerCalendarios (si se necesita)
TreeViewNavegacion 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

EscenarioHerramientaRazon
DataGrid con edicion inlineDevExtreme FormIntegrado con el grid
Formularios CRUD simplesDevExtreme FormRapido, validacion built-in
Wizard multi-paso (credito)React Hook Form + ZodMejor control de estado entre pasos
Formularios con logica complejaReact Hook Form + ZodMejor composicion y testing
Formularios en apps publicasReact Hook Form + ZodMenor 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 estadoHerramientaEjemplo
Server state (datos de API)TanStack QueryLista de productos, detalle de credito
Form state (inputs)React Hook FormCampos del formulario activo
UI state (local)useStateModal abierto/cerrado, tab activo
UI state (compartido)ZustandSidebar 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

ElementoConvencionEjemplo
ComponentesPascalCaseProductCard.tsx
HookscamelCase con useuseProducts.ts
SchemascamelCase + SchemacreateProductSchema
TypesPascalCaseCreditProductDto
StorescamelCase con use + StoreuseLayoutStore.ts
UtilscamelCaseformatCurrency.ts
ConstantesUPPER_SNAKE_CASEMAX_PAGE_SIZE
Carpetaskebab-casedata-display/
Archivos componentePascalCaseProductForm.tsx
Archivos no-componentecamelCaseuseProducts.ts

Testing

Que testear

TipoQueHerramienta
UnitSchemas Zod, utils, calculosVitest
ComponentComponentes con logicaVitest + Testing Library
IntegrationFlujos completos (form submit, navigation)Vitest + Testing Library + MSW
E2EFlujos 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 label asociado (via htmlFor o aria-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-live para 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 staleTime apropiado (no refetch innecesario)
  • Virtualizacion para listas largas (TanStack Virtual)
  • Bundle analysis periodico (vite-bundle-visualizer)

Reimagine Tech LLC — Documentacion Interna