Skip to content

Arquitectura Frontend - Sistema Bautista

Esta guía describe la arquitectura del frontend del sistema Bautista, que está en proceso de migración de tecnología Legacy (PHP SSR) hacia React moderno.

⚠️ Arquitectura Híbrida en Coexistencia

El sistema mantiene tres tipos de arquitectura frontend funcionando simultáneamente:

1. Legacy PHP SSR (⚠️ Solo Mantenimiento)

Ver documentación completa: Arquitectura Legacy

  • Views: .php en php/components/mod-{modulo}/
  • JavaScript: Vanilla JS en js/view/{modulo}/
  • Modales: .html/.php con .js propio, fetch dinámico
  • Comunicación: Fetch directo al backend API
  • Proxy: php/backend/ DEPRECADO (usar fetch directo)
  • Estado: ⚠️ DEPRECADO - Solo mantenimiento

Ejemplo:

php/components/mod-venta/forms/tarjeta.php  (View)
js/view/venta/tarjeta.js                    (Interactividad)

2. React Montable (Migración Gradual)

Componentes React montados en contexto legacy PHP.

A. Full View React

  • Archivo .tsx completo
  • Montaje directo en .php shell
  • Ejemplo: Conceptos de retenciones de ganancia

B. Component Mount Only

  • Componente embebido en view legacy
  • API expuesta para interacción con .js legacy
  • Ejemplo: Campo carteras de cliente

3. React Full Module (Arquitectura Moderna)

Ver: Estructura de Módulos React

  • App completa con React Router
  • Sidebar propio configurable
  • Contextos propios
  • Ejemplo en producción: Módulo Membresías (ts/mod-membresias/)

Decisión: ¿Qué Arquitectura Usar?

Tipo FeatureArquitecturaReferencia
Mantenimiento código existente⚠️ Legacy PHP SSRlegacy-architecture.md
Campo/modal pequeño✅ React Component MountEste doc - Component Mount Only
Vista completa✅ React Full View MountEste doc - Full View React
Módulo completo✅ React Full Moduleestructura-modulos-react.md

Estado Actual del Frontend

El sistema tiene una arquitectura híbrida con dos stacks coexistiendo:

1. Legacy Platform (PHP SSR)

Usada en módulos antiguos:

  • Rendering: Server-Side Rendering (PHP)
  • Templates: PHP templates
  • JavaScript: Vanilla JS / jQuery
  • Estilos: CSS personalizado
  • Comunicación: Formularios tradicionales + AJAX

2. React SPA (Moderna)

En producción en módulos nuevos y migrados:

  • Framework: React
  • Módulos migrados: Membresía (ver rama feature/modulo-membresia)
  • Comunicación: API REST JSON
  • Autenticación: JWT via headers

Ejemplo de módulo migrado: El módulo de Membresía está completamente implementado en React con:

  • Gestión de estado moderno
  • Componentes reutilizables
  • Integración con API REST
  • UI moderna y responsive

Stack Tecnológico React (bautista-app)

Tecnologías Principales

Core:

  • React: 19+ - Framework UI
  • TypeScript: 5.8+ - Type safety con modo strict
  • Vite: 7+ - Build tool y dev server

UI Framework:

  • Material UI (MUI): 7+ - Componentes UI
  • MUI X Data Grid: Para tablas complejas con server-side rendering
  • MUI Icons: Iconos prediseñados

State & Data:

  • TanStack Query (React Query): 6+ - Server state management y caching
  • Axios: HTTP client con interceptores
  • Zustand: Client state management (opcional para estado UI local)

Forms & Validation:

  • React Hook Form: 7+ - Form management con validación integrada
  • Zod: 3+ - Schema validation runtime

Routing:

  • React Router DOM: 7+ - Client-side routing

Estructura del Proyecto React

bautista-app/
├── public/                     # Archivos estáticos
├── ts/                         # Código TypeScript
│   ├── api/                    # Configuración API
│   │   ├── api.ts              # Instancia Axios configurada
│   │   └── endpoints/          # Endpoints por módulo
│   ├── core/                   # Código compartido
│   │   ├── components/         # Componentes reutilizables
│   │   ├── hooks/              # Custom hooks
│   │   ├── types/              # Tipos TypeScript
│   │   └── utils/              # Utilidades
│   ├── config/                 # Configuración
│   │   └── theme.ts            # Tema Material UI
│   └── modules/                # Módulos por dominio
│       ├── membresia/
│       │   ├── components/     # Componentes del módulo
│       │   ├── hooks/          # Hooks del módulo
│       │   ├── schemas/        # Schemas Zod
│       │   ├── services/       # Servicios API
│       │   └── types/          # Tipos del módulo
│       ├── auth/
│       ├── ventas/
│       └── ...
├── tsconfig.json               # Configuración TypeScript
├── vite.config.ts              # Configuración Vite
└── package.json

Configuración TypeScript

El proyecto utiliza TypeScript en modo strict con las siguientes características:

  • Target ES2022 con soporte para módulos modernos
  • Path aliases configurados (@/ apunta a ./ts/)
  • React JSX con transformación moderna
  • Validaciones estrictas habilitadas (noUnusedLocals, noUnusedParameters, etc.)
  • Resolución de módulos tipo bundler para compatibilidad con Vite

Configuración de API y Autenticación

Cliente HTTP (Axios)

El sistema utiliza una instancia centralizada de Axios configurada en:

  • Ubicación: ts/api/api.ts
  • Configuración base: ts/config/app.ts (URL del API hardcodeada, no variables de entorno)
  • Timeout: 10 segundos
  • withCredentials: Habilitado para enviar cookies en requests

Sistema de Autenticación

Autenticación basada en cookies:

  • Token de acceso almacenado en cookie ACCESS_TOKEN
  • Refresh token también manejado vía cookies
  • NO se usa localStorage para tokens

Interceptores de Request:

  • Lee el token de la cookie ACCESS_TOKEN
  • Agrega header Authorization: Bearer {token} automáticamente
  • Agrega header X-Requested-With: Axios

Interceptores de Response:

  • 401 (Unauthorized):
    • Detecta INVALID_ACCESS_TOKEN e intenta refresh automático
    • Si falla el refresh o es INVALID_REFRESH_TOKEN, redirige a logout
    • Deshabilita toda la UI antes de redirigir (botones, forms, links)
  • 422 (Validation Error): Lanza ValidationError con mensaje del backend
  • 400 (Bad Request): Lanza BadRequestError
  • 409 (Conflict): Lanza ConflictError

Manejo de errores tipados: Todos los errores tienen propiedades name y message para manejo específico en componentes.

Patrón de Módulos

Cada módulo sigue una estructura consistente demostrada en el módulo de membresías en producción:

ts/mod-membresias/
├── components/       # Componentes React del módulo
├── config/          # Configuración (routes, queryKeys)
├── hooks/           # React Query hooks
├── mappers/         # Transformación Backend ↔ Frontend
├── schemas/         # Validación con Zod
├── services/        # Llamadas API
├── types/           # TypeScript types
└── views/           # Páginas/Vistas principales

1. Schemas (Zod) - Validación Frontend

Ejemplo real: ts/mod-membresias/schemas/miembro.schema.ts

typescript
import { z } from 'zod';
import { isValidFrontendDate } from '../../core/utils/dateConversion.js';

/**
 * Schema para validar datos de membresía (extData)
 */
const extDataSchema = z.object({
    jubilado: z.boolean(),
    nacimiento: z
        .string()
        .min(1, 'La fecha de nacimiento es requerida')
        .refine((val) => isValidFrontendDate(val), {
            message: 'Fecha de nacimiento inválida (formato: dd/mm/yyyy)',
        }),
    sexo: z.nativeEnum(Sexo),
    fichaMedicaCompleta: z.boolean(),
    fichaMedica: z.string().nullable().optional(),
});

/**
 * Schema para crear un nuevo miembro
 */
export const miembroCreateSchema = z.object({
    // Datos básicos
    nombre: z
        .string()
        .min(3, 'El nombre debe tener al menos 3 caracteres')
        .max(55, 'El nombre no puede exceder 55 caracteres'),
    identificacion: z.coerce.number().nullable().optional(),
    email: z
        .preprocess((val) => {
            if (typeof val === 'string') {
                const trimmed = val.trim();
                return trimmed === '' ? null : trimmed;
            }
            return val;
        }, z.email('Email inválido').max(50).nullable().optional())
        .nullable()
        .optional(),

    // Relaciones (objetos anidados para localidad, números para selects)
    localidad: z.object({
        id: z.number(),
        nombre: z.string().optional(),
    }),
    condicionIva: z.number().nullable(),
    vendedor: z.number().nullable(),

    // Datos de membresía
    idCategoria: z.number().nullable(),
    extData: extDataSchema,
});

export type MiembroCreateSchemaType = z.infer<typeof miembroCreateSchema>;

/**
 * Schema para actualizar un miembro existente
 */
export const miembroUpdateSchema = miembroCreateSchema.extend({
    id: z.number().optional(),
});

export type MiembroUpdateSchemaType = z.infer<typeof miembroUpdateSchema>;

Características importantes:

  • Validaciones personalizadas con .refine() para fechas, lógica compleja
  • Transformaciones con .preprocess() para normalizar datos (ej: email vacío → null)
  • Tipos inferidos con z.infer<> para type-safety
  • Validación de enums con z.nativeEnum()
  • Objetos anidados para relaciones complejas
  • Reutilización de schemas (extDataSchema dentro de miembroCreateSchema)

2. Mappers - Transformación Backend ↔ Frontend

¿Por qué usamos mappers?

  • Backend usa snake_case (PHP convention)
  • Frontend usa camelCase (JavaScript convention)
  • Separación clara de responsabilidades
  • Transformaciones de tipos (fechas, estructuras)

Ejemplo real: ts/mod-membresias/mappers/miembro.mapper.ts

typescript
import { backendDateToFrontend, frontendDateToBackend } from '../../core/utils/dateConversion.js';

// Estructura del backend (snake_case)
export interface MiembroBackend {
    id: number;
    nombre: string;
    identificacion?: number | null;
    email?: string | null;
    condicion_iva?: { id: number; nombre: string } | null;
    nro_iibb?: string | null;
    localidad?: { id: number; nombre: string; cod_post?: string } | null;
    ext_data?: {
        id_ordcon: number;
        jubilado: boolean;
        nacimiento: string;  // Backend: "2000-12-31"
        sexo: Sexo;
        ficha_medica_completa: boolean;
        fecha_baja?: string | null;
        motivo_baja?: string | null;
    } | null;
    categoria?: { id: number; nombre: string } | null;
}

/**
 * Mapper: Backend (snake_case) → Frontend (camelCase)
 */
export const mapMiembroToFrontend = (item: MiembroBackend): Miembro => ({
    id: item.id,
    nombre: item.nombre,
    identificacion: item.identificacion,
    email: item.email,
    condicionIva: item.condicion_iva ? {
        id: item.condicion_iva.id,
        nombre: item.condicion_iva.nombre,
    } : null,
    nroIibb: item.nro_iibb,
    localidad: item.localidad ? {
        id: item.localidad.id,
        nombre: item.localidad.nombre,
        ...(item.localidad.cod_post !== undefined && { codPost: item.localidad.cod_post }),
    } : null,
    extData: item.ext_data ? {
        idOrdcon: item.ext_data.id_ordcon,
        jubilado: item.ext_data.jubilado,
        // Transformación de fechas: "2000-12-31" → "31/12/2000"
        nacimiento: backendDateToFrontend(item.ext_data.nacimiento),
        sexo: item.ext_data.sexo,
        fichaMedicaCompleta: item.ext_data.ficha_medica_completa,
        fechaBaja: item.ext_data.fecha_baja ? backendDateToFrontend(item.ext_data.fecha_baja) : null,
        motivoBaja: item.ext_data.motivo_baja ?? null,
    } : null,
    categoria: item.categoria ? {
        id: item.categoria.id,
        nombre: item.categoria.nombre,
    } : null,
});

/**
 * Mapper: Frontend (camelCase) → Backend (snake_case)
 */
export const mapMiembroToBackend = (
    data: MiembroCreateSchemaType | MiembroUpdateSchemaType
): Record<string, unknown> => {
    return {
        nombre: data.nombre,
        identificacion: data.identificacion ?? null,
        email: data.email ?? null,
        localidad: data.localidad?.id ? { id: data.localidad.id } : null,
        condicion_iva: data.condicionIva ? { id: data.condicionIva } : null,
        nro_iibb: data.nroIibb ?? null,
        id_categoria: data.idCategoria ?? null,
        ext_data: {
            jubilado: data.extData.jubilado,
            // Transformación de fechas: "31/12/2000" → "2000-12-31"
            nacimiento: frontendDateToBackend(data.extData.nacimiento),
            sexo: data.extData.sexo,
            ficha_medica_completa: data.extData.fichaMedicaCompleta,
        },
    };
};

Ventajas del patrón Mapper:

  • Independencia de convenciones de nomenclatura backend/frontend
  • Transformaciones centralizadas (fechas, estructuras)
  • Type-safety en ambas direcciones
  • Fácil mantenimiento cuando cambia el backend

3. Services (API calls)

Ejemplo real: ts/mod-membresias/services/miembro.service.ts

typescript
import api from '../../api/api.js';
import type { ApiResponse, QueryOptions, PaginatedResponse } from '../../api/api.types.js';
import { mapMiembroToFrontend, mapMiembroToBackend, type MiembroBackend } from '../mappers/miembro.mapper.js';

const basePath = 'mod-membresia/';

export type MemberStatus = 'all' | 'active' | 'inactive' | 'non_member';

export interface MiembroQueryOptions extends QueryOptions {
    categoriaId?: number;
    memberStatus?: MemberStatus;
}

export const MiembroService = {
    /**
     * Get all miembros with server-side pagination, filtering, and sorting
     * Por defecto trae sólo miembros activos (memberStatus = 'active')
     */
    getAll: async (options: MiembroQueryOptions = {}) => {
        const result = await api.get<ApiResponse<PaginatedResponse<MiembroBackend>>>(
            `${basePath}miembros`,
            {
                params: {
                    scope: options.scope ?? 'max',
                    include: options.include ?? null,
                    filter: options.filter,
                    pageIndex: options.pageIndex,
                    pageSize: options.pageSize,
                    order: options.order,
                    columnFilter: options.columnFilter,
                    ...(options.categoriaId != null ? { categoriaId: options.categoriaId } : {}),
                    memberStatus: options.memberStatus ?? 'active', // Por defecto solo activos
                },
            }
        );

        const paginatedResponse = result.data.data;

        // Validación defensiva
        if (!paginatedResponse?.data || !Array.isArray(paginatedResponse.data)) {
            console.error('Invalid paginated response structure:', paginatedResponse);
            throw new Error('Invalid API response structure for miembros');
        }

        // Mapear cada item del backend al frontend
        return {
            data: paginatedResponse.data.map(mapMiembroToFrontend),
            meta: paginatedResponse.meta,
        };
    },

    /**
     * Get miembro by ID
     */
    getById: async (id: number) => {
        const result = await api.get<ApiResponse<MiembroBackend>>(
            `${basePath}miembros/${id}`,
            {
                params: {
                    scope: 'max',
                    include: ['categoria', 'disciplinas', 'productos'],
                },
            }
        );
        return mapMiembroToFrontend(result.data.data);
    },

    /**
     * Dar de baja un miembro (DELETE con payload)
     */
    darBaja: async (id: number, data: BajaMiembroData) => {
        const payload = mapBajaMiembroToBackend(data);
        const result = await api.delete<ApiResponse<void>>(
            `${basePath}miembros/${id}`,
            { data: payload }
        );
        return result.data;
    },

    /**
     * Create new miembro
     */
    create: async (data: MiembroCreateSchemaType) => {
        const payload = mapMiembroToBackend(data);
        const result = await api.post<ApiResponse<MiembroBackend>>(
            `${basePath}miembros`,
            payload
        );
        return mapMiembroToFrontend(result.data.data);
    },

    /**
     * Update existing miembro
     */
    update: async (id: number, data: MiembroUpdateSchemaType) => {
        const payload = mapMiembroToBackend(data);
        const result = await api.put<ApiResponse<MiembroBackend>>(
            `${basePath}miembros/${id}`,
            payload
        );
        return mapMiembroToFrontend(result.data.data);
    },

    /**
     * Reactivate a member using POST endpoint
     */
    reactivar: async (id: number) => {
        const result = await api.post<ApiResponse<MiembroBackend>>(
            `${basePath}miembros/${id}/reactivar`
        );
        return mapMiembroToFrontend(result.data.data);
    },
};

Características importantes:

  • Tipado estricto con genéricos de TypeScript
  • Server-side pagination con QueryOptions
  • Mappers para transformar datos en ambas direcciones
  • Validación defensiva de respuestas del API
  • Parámetros opcionales con defaults (memberStatus = 'active')
  • Documentación JSDoc en cada método

4. Hooks (React Query)

Ejemplo real: ts/mod-membresias/hooks/useMiembro.ts

typescript
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { MiembroService } from '../services/miembro.service.js';
import { membresiaQueryKeys } from '../config/queryKeys.js';

interface UseMiembroListOptions {
    queryOptions?: MiembroQueryOptions;
    enabled?: boolean;
}

/**
 * Hook para obtener una lista de miembros con paginación y filtros
 */
export function useMiembroList(options: UseMiembroListOptions = {}) {
    const { queryOptions = {}, enabled = true } = options;

    return useQuery({
        queryKey: membresiaQueryKeys.miembros.list(queryOptions),
        queryFn: () => MiembroService.getAll(queryOptions),
        staleTime: 5 * 60 * 1000, // 5 minutos - datos frescos por 5 min
        enabled,
    });
}

/**
 * Hook para obtener un miembro por ID
 */
export function useMiembroById(id: number, enabled = true) {
    return useQuery({
        queryKey: membresiaQueryKeys.miembros.detail(id),
        queryFn: () => MiembroService.getById(id),
        staleTime: 5 * 60 * 1000,
        enabled,
    });
}

/**
 * Hook para crear un miembro
 */
export function useCreateMiembro() {
    const queryClient = useQueryClient();

    return useMutation({
        mutationFn: (data: MiembroCreateSchemaType) => MiembroService.create(data),
        onSuccess: (newMiembro) => {
            // Invalidar todas las listas de miembros
            queryClient.invalidateQueries({ queryKey: membresiaQueryKeys.miembros.lists() });

            // Optimistic update: agregar el nuevo miembro al cache de detalle
            queryClient.setQueryData(
                membresiaQueryKeys.miembros.detail(newMiembro.id),
                newMiembro
            );
        },
    });
}

/**
 * Hook para actualizar un miembro
 */
export function useUpdateMiembro() {
    const queryClient = useQueryClient();

    return useMutation({
        mutationFn: ({ id, data }: { id: number; data: MiembroUpdateSchemaType }) =>
            MiembroService.update(id, data),
        onMutate: async ({ id, data }) => {
            // Cancelar queries en curso para evitar race conditions
            await queryClient.cancelQueries({
                queryKey: membresiaQueryKeys.miembros.detail(id)
            });

            // Snapshot del valor anterior
            const previousMiembro = queryClient.getQueryData<Miembro>(
                membresiaQueryKeys.miembros.detail(id)
            );

            // Optimistic update - actualizar UI inmediatamente
            if (previousMiembro) {
                queryClient.setQueryData(
                    membresiaQueryKeys.miembros.detail(id),
                    { ...previousMiembro, ...data }
                );
            }

            // Retornar contexto para rollback si falla
            return { previousMiembro };
        },
        onError: (_err, { id }, context) => {
            // Revertir en caso de error
            if (context?.previousMiembro) {
                queryClient.setQueryData(
                    membresiaQueryKeys.miembros.detail(id),
                    context.previousMiembro
                );
            }
        },
        onSuccess: (updatedMiembro, { id }) => {
            // Actualizar cache con datos reales del servidor
            queryClient.setQueryData(
                membresiaQueryKeys.miembros.detail(id),
                updatedMiembro
            );

            // Invalidar listas para que se actualicen
            queryClient.invalidateQueries({
                queryKey: membresiaQueryKeys.miembros.lists()
            });
        },
    });
}

/**
 * Hook para dar de baja un miembro
 */
export function useDarBajaMiembro() {
    const queryClient = useQueryClient();

    return useMutation({
        mutationFn: ({ id, data }: { id: number; data: BajaMiembroData }) =>
            MiembroService.darBaja(id, data),
        onSuccess: (_, { id }) => {
            // Invalidar el detalle del miembro
            queryClient.invalidateQueries({
                queryKey: membresiaQueryKeys.miembros.detail(id)
            });

            // Invalidar todas las listas de miembros
            queryClient.invalidateQueries({
                queryKey: membresiaQueryKeys.miembros.lists()
            });
        },
    });
}

/**
 * Hook para reactivar un miembro
 */
export function useReactivarMiembro() {
    const queryClient = useQueryClient();

    return useMutation({
        mutationFn: (id: number) => MiembroService.reactivar(id),
        onSuccess: (reactivatedMiembro) => {
            // Actualizar el cache del detalle
            queryClient.setQueryData(
                membresiaQueryKeys.miembros.detail(reactivatedMiembro.id),
                reactivatedMiembro
            );

            // Invalidar todas las listas de miembros
            queryClient.invalidateQueries({
                queryKey: membresiaQueryKeys.miembros.lists()
            });
        },
    });
}

Características importantes:

  • Query Keys Factory (membresiaQueryKeys) para organizar keys de cache
  • Optimistic Updates en mutaciones para UX instantánea
  • Rollback automático si la mutación falla
  • Cache invalidation selectiva (solo lo necesario)
  • staleTime configurado para evitar refetches innecesarios
  • enabled flag para deshabilitar queries condicionales
  • Tipos inferidos de schemas y services

5. Componentes - Tabla con Server-Side Rendering

Ejemplo real: ts/mod-membresias/components/MiembroTable.tsx

Este componente demuestra una tabla con paginación, filtrado y ordenamiento del lado del servidor (Server-Side Rendering), un patrón muy usado:

typescript
import { useMemo } from 'react';
import type { MRT_ColumnDef } from 'material-react-table';
import Box from '@mui/material/Box';
import FormControl from '@mui/material/FormControl';
import FormLabel from '@mui/material/FormLabel';
import RadioGroup from '@mui/material/RadioGroup';
import FormControlLabel from '@mui/material/FormControlLabel';
import Radio from '@mui/material/Radio';
import RestoreIcon from '@mui/icons-material/Restore';
import { DataTable } from '../../core/components/DataTable.js';
import type { DataTableAction } from '../../core/components/DataTable.js';
import type { Miembro } from '../types/miembro.types.js';
import type { MemberStatus } from '../services/miembro.service.js';
import { useMiembroTableSSR } from '../hooks/useMiembroTableSSR.js';

interface MiembroTableProps {
    reloadSignal?: number;
    onDelete?: (miembro: Miembro) => void;
    onEdit?: (miembro: Miembro) => void;
    onReactivar?: (miembro: Miembro) => void;
    categoriaId?: number | null;
    enableStatusFilter?: boolean;
    defaultMemberStatus?: MemberStatus;
}

/**
 * Tabla de miembros con paginación, filtrado y ordenamiento del lado del servidor (SSR)
 */
export function MiembroTable({
    reloadSignal,
    onDelete,
    onEdit,
    onReactivar,
    categoriaId,
    enableStatusFilter = true,
    defaultMemberStatus = 'active',
}: MiembroTableProps) {
    // Hook personalizado que encapsula toda la lógica SSR
    const {
        data,
        meta,
        isLoading,
        error,
        pagination,
        sorting,
        columnFilters,
        memberStatus,
        handlePaginationChange,
        handleSortingChange,
        handleColumnFiltersChange,
        setMemberStatus,
    } = useMiembroTableSSR({ reloadSignal, categoriaId, defaultMemberStatus });

    // Definición de columnas con useMemo para evitar re-renders
    const columns: MRT_ColumnDef<Miembro>[] = useMemo(() => {
        const baseColumns: MRT_ColumnDef<Miembro>[] = [
            { accessorKey: 'id', header: 'Código', size: 100, enableColumnFilter: true },
            { accessorKey: 'nombre', header: 'Nombre', size: 300, enableColumnFilter: true },
        ];

        // Columnas condicionales según el estado
        if (memberStatus !== 'non_member') {
            baseColumns.push({
                accessorKey: 'categoria.nombre',
                header: 'Categoría',
                size: 200,
                enableSorting: false,
                Cell: ({ row }) => row.original.categoria?.nombre ?? '-',
            });
        }

        // Columnas adicionales para inactivos
        if (memberStatus === 'inactive') {
            baseColumns.push(
                {
                    accessorKey: 'extData.fechaBaja',
                    header: 'Fecha de Baja',
                    Cell: ({ row }) => row.original.extData?.fechaBaja ?? '-',
                },
                {
                    accessorKey: 'extData.motivoBaja',
                    header: 'Motivo de Baja',
                    Cell: ({ row }) => row.original.extData?.motivoBaja ?? '-',
                }
            );
        }

        return baseColumns;
    }, [memberStatus]);

    // Acciones dinámicas según el estado del miembro
    const actions: DataTableAction<Miembro>[] = useMemo(() => {
        if (memberStatus === 'active') {
            return [
                ...(onEdit ? [{ type: 'edit' as const, onClick: onEdit }] : []),
                ...(onDelete ? [{ type: 'delete' as const, onClick: onDelete }] : []),
            ];
        } else if (memberStatus === 'inactive') {
            return onReactivar
                ? [{ type: 'custom' as const, icon: <RestoreIcon />, onClick: onReactivar }]
                : [];
        }
        return onEdit ? [{ type: 'edit' as const, onClick: onEdit }] : [];
    }, [memberStatus, onEdit, onDelete, onReactivar]);

    if (error) {
        return <div>Error al cargar miembros: {error.message}</div>;
    }

    return (
        <Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
            {/* Filtro de estado de miembro */}
            {enableStatusFilter && (
                <Box sx={{ pl: 2, pt: 2 }}>
                    <FormControl component="fieldset">
                        <FormLabel>Filtrar por estado</FormLabel>
                        <RadioGroup
                            row
                            value={memberStatus}
                            onChange={(e) => setMemberStatus(e.target.value as MemberStatus)}
                        >
                            <FormControlLabel value="active" control={<Radio />} label="Activos" />
                            <FormControlLabel value="inactive" control={<Radio />} label="Inactivos" />
                            <FormControlLabel value="non_member" control={<Radio />} label="No Miembros" />
                        </RadioGroup>
                    </FormControl>
                </Box>
            )}

            {/* Tabla */}
            <DataTable<Miembro>
                resourceName="miembros"
                data={data}
                columns={columns}
                actions={actions}
                manualPagination
                rowCount={meta?.totalRowCount ?? 0}
                onPaginationChange={handlePaginationChange}
                manualSorting
                onSortingChange={handleSortingChange}
                manualFiltering
                onColumnFiltersChange={handleColumnFiltersChange}
                state={{ isLoading, pagination, sorting, columnFilters }}
                initialState={{ sorting: [{ id: 'id', desc: false }] }}
            />
        </Box>
    );
}

Características del patrón de componentes:

  • Server-Side Rendering: Paginación/ordenamiento/filtrado en servidor
  • useMemo: Optimización de re-renders
  • Columnas dinámicas: Cambian según el estado
  • Acciones condicionales: Diferentes botones según estado
  • Props opcionales con defaults
  • Componentes reutilizables (DataTable del core)
  • Manejo de errores con UI feedback
  • TypeScript strict

Configuración de Material UI

Ubicación: ts/config/theme.ts

El proyecto utiliza un tema personalizado de Material UI con:

  • Paleta de colores personalizada (primary, secondary)
  • Tipografía configurada (Roboto como fuente principal)
  • Sobrescrituras de componentes: Por ejemplo, botones sin transformación automática a mayúsculas

Providers y Setup Principal

Ubicación: ts/main.tsx

La aplicación se configura con múltiples providers:

  1. React.StrictMode: Modo estricto de React para desarrollo
  2. QueryClientProvider: Proveedor de TanStack Query con configuración:
    • refetchOnWindowFocus: false - No refetch automático al cambiar de pestaña
    • retry: 1 - Solo un reintento en caso de error
  3. ThemeProvider: Proveedor del tema de Material UI
  4. CssBaseline: Normalización de estilos CSS de MUI

Sistema de Componentes Montables

El proyecto React utiliza un patrón de componentes montables que permite integrar componentes React dentro de páginas PHP Legacy:

Características:

  • Componentes independientes que se pueden montar en cualquier elemento DOM
  • Comunicación mediante props y eventos personalizados
  • Coexistencia con código Legacy
  • Migración incremental sin reescribir todo el sistema

Ejemplo de uso: Ver CLAUDE.md en bautista-app para más detalles sobre esta arquitectura híbrida.

Convención para Nuevas Vistas React (Contexto Legacy)

Toda nueva vista implementada en React y destinada a ser utilizada dentro de una página PHP Legacy (es decir, montada en un contexto no React completo) debe seguir la siguiente convención:

  • El componente principal de la vista debe terminar con el sufijo App (ej: MiNuevaFuncionalidadApp.tsx).
  • Este componente *App debe ser el punto de montaje directo. No debe existir un archivo intermedio de montaje que a su vez renderice el *App.
  • La invocación para el montaje desde el contexto Legacy (PHP/JS) debe apuntar directamente al componente *App.

Justificación: Esta convención simplifica la identificación de los puntos de entrada para las vistas React en entornos híbridos y asegura un montaje consistente y directo, evitando capas de abstracción innecesarias para la integración Legacy.

API REST (Backend separation)

  • Protocolo: HTTP/REST
  • Formato: JSON
  • Autenticación: JWT via headers

Arquitectura Frontend

Modelo Legacy (Actual)

El sistema utiliza rendering en servidor con PHP:

┌─────────────────────────────────────┐
│   Browser                           │
├─────────────────────────────────────┤
│   HTML/CSS/JS (Legacy)              │  ← Rendered por PHP
├─────────────────────────────────────┤
│   AJAX Calls                        │  ← Comunicación con API
├─────────────────────────────────────┤
│   Backend API (REST)                │  ← Endpoints JSON
└─────────────────────────────────────┘

Estructura de Vistas

Las vistas se organizan por módulo:

views/                   (ubicación por confirmar)
├── auth/
│   ├── login.php
│   └── register.php
├── ventas/
│   ├── comprobantes.php
│   ├── facturacion.php
│   └── informes/
│       └── ventas-provincias.php
├── tesoreria/
│   ├── recibos.php
│   └── retenciones.php
└── ...

Tipos de Componentes Frontend

1. Listados (Grillas/Tablas)

Propósito: Mostrar colecciones de datos con filtros, búsqueda y paginación.

Características:

  • Tabla con columnas configurables
  • Filtros por campos
  • Búsqueda global
  • Ordenamiento por columnas
  • Paginación
  • Acciones por fila (editar, eliminar, ver detalle)

Permisos típicos:

  • MODULO_RECURSO_VIEW: Ver listado

Ejemplo: Listado de conceptos de retenciones

  • Columnas: Nombre, Cuenta, Tipo, Valor, Impuesto/Servicio, Acumula
  • Filtros: Por tipo (P/F), por impser (Sí/No)
  • Acciones: Editar, Eliminar

2. Formularios (Alta/Edición)

Propósito: Crear o modificar registros.

Características:

  • Campos de entrada tipados
  • Validación client-side
  • Validación server-side
  • Mensajes de error inline
  • Botones de guardar/cancelar
  • Confirmaciones para cambios

Permisos requeridos: Los permisos en el sistema son jerárquicos y por funcionalidad completa, no granulares por acción:

  • Formato: MODULO_SECCION_FUNCIONALIDAD
  • Niveles: 1 (Módulo) → 2 (Sección) → 3 (Funcionalidad) → 4 (Sub-opción)
  • Control: Acceso completo a la funcionalidad (no hay VIEW/WRITE/DELETE separados)

Ejemplos:

  • VENTAS_BASES_CLIENTES: Acceso completo al ABM de clientes
  • VENTAS_MOV_FACT: Acceso a facturación electrónica
  • CONFIG_PERMISOS_GRUPOS: Acceso a gestión de grupos

Tipos de inputs:

  • Text inputs
  • Number inputs
  • Selects/Dropdowns
  • Checkboxes
  • Date pickers
  • Autocomplete

Ejemplo: Formulario de retención

┌─────────────────────────────────────┐
│ Alta/Edición de Retención           │
├─────────────────────────────────────┤
│ Nombre: [_______________]           │
│ Cuenta: [_______________]           │
│ Tipo: (•) Porcentaje ( ) Fijo       │
│ Valor: [_______________]            │
│ ☑ Es Impuesto/Servicio              │
│ ☑ Acumula con anteriores            │
│                                     │
│ [Guardar] [Cancelar]                │
└─────────────────────────────────────┘

3. Vistas de Detalle

Propósito: Mostrar información completa de un registro.

Características:

  • Solo lectura (usualmente)
  • Información organizada en secciones
  • Datos relacionados
  • Historial/auditoría
  • Acciones disponibles

Permisos típicos:

  • MODULO_RECURSO_VIEW: Ver detalle

4. Modales/Dialogs

Propósito: Mostrar información o formularios sin cambiar de página.

Tipos:

  • Confirmación: Confirmar acciones destructivas
  • Información: Mostrar detalles rápidos
  • Formularios: Alta/edición rápida
  • Selección: Elegir entre opciones

Ejemplo: Modal de confirmación de eliminación

┌─────────────────────────────────────┐
│ ⚠️  Confirmar eliminación           │
├─────────────────────────────────────┤
│ ¿Está seguro que desea eliminar     │
│ el concepto de retención "IIBB"?    │
│                                     │
│ Esta acción no se puede deshacer.   │
│                                     │
│ [Cancelar] [Eliminar]               │
└─────────────────────────────────────┘

5. Reportes/Informes

Propósito: Mostrar información agregada y analítica.

Características:

  • Formulario de parámetros (fechas, filtros)
  • Visualización tabular o gráfica
  • Exportación (PDF, Excel)
  • Totales y subtotales
  • Agrupaciones

Permisos típicos:

  • MODULO_INFORMES_VIEW: Ver informes

Ejemplo: Ventas por Provincias por Rubros

  • Parámetros: Rango de fechas, rango de rubros
  • Vista: Modal Legacy PHP SSR
  • Formato: Tabla agrupada por provincia
  • Exportación: PDF

Estructura de Menú

Sistema
├── Ventas
│   ├── Comprobantes
│   ├── Facturación
│   └── Informes
│       ├── Ventas por cliente
│       ├── Ventas por producto
│       └── Ventas por provincias
│           └── Por rubros (modal)
├── Tesorería
│   ├── Recibos
│   ├── Conceptos de retenciones
│   └── Movimientos de caja
├── Cuenta Corriente
├── Compras
├── Contabilidad
├── Stock
└── CRM

URLs (Legacy)

El sistema legacy probablemente usa URLs como:

/index.php?modulo=ventas&accion=listar
/index.php?modulo=ventas&accion=formulario&id=123
/index.php?modulo=tesoreria&accion=retenciones

Sistema de Permisos

Estructura de Permisos

El sistema utiliza permisos jerárquicos por funcionalidad completa, no granulares por operación.

Formato: MODULO_SECCION_FUNCIONALIDAD

Niveles jerárquicos:

  1. Nivel 1 - Módulo: Acceso al módulo principal (ej: VENTAS, CONFIG, TESORERIA)
  2. Nivel 2 - Sección: Acceso a secciones dentro del módulo (ej: VENTAS_BASES, VENTAS_MOV, VENTAS_INF)
  3. Nivel 3 - Funcionalidad: Acceso a funcionalidad específica (ej: VENTAS_BASES_CLIENTES, VENTAS_MOV_FACT)
  4. Nivel 4 - Sub-opción: Opciones dentro de funcionalidades (ej: VENTAS_BASES_LISTA-PRECIO_RANGO)

Ejemplos reales:

VENTAS                              (Nivel 1: Módulo)
├── VENTAS_BASES                    (Nivel 2: Sección)
│   ├── VENTAS_BASES_CLIENTES       (Nivel 3: ABM Clientes)
│   ├── VENTAS_BASES_ARTICULOS      (Nivel 3: ABM Artículos)
│   └── VENTAS_BASES_LISTA-PRECIO   (Nivel 3: Listas)
│       ├── VENTAS_BASES_LISTA-PRECIO_RANGO  (Nivel 4: Por rango)
│       └── VENTAS_BASES_LISTA-PRECIO_COSTO  (Nivel 4: Por costo)
├── VENTAS_MOV                      (Nivel 2: Sección)
│   ├── VENTAS_MOV_FACT             (Nivel 3: Facturación)
│   └── VENTAS_MOV_PEDIDOS          (Nivel 3: Pedidos)
└── VENTAS_INF                      (Nivel 2: Sección)
    ├── VENTAS_INF_CLIENTES         (Nivel 3: Informe clientes)
    └── VENTAS_INF_SUBDIARIO        (Nivel 3: Subdiario)

Importante: Cada permiso otorga acceso completo a la funcionalidad (listar, crear, editar, eliminar). No hay permisos separados por operación como VIEW/WRITE/DELETE.

Control de Acceso en UI

A nivel de menú: No mostrar opciones sin permiso A nivel de vista: Verificar permisos antes de renderizar A nivel de funcionalidad: Mostrar/ocultar toda la funcionalidad según permiso

Ejemplo Legacy PHP:

php
<?php if (hasPermission('VENTAS_BASES_CLIENTES')): ?>
    <!-- Mostrar toda la funcionalidad de ABM clientes -->
    <div class="clientes-section">
        <button onclick="nuevoCliente()">Nuevo</button>
        <button onclick="editarCliente(<?= $id ?>)">Editar</button>
        <button onclick="eliminarCliente(<?= $id ?>)">Eliminar</button>
    </div>
<?php endif; ?>

Ejemplo React:

jsx
{hasPermission('VENTAS_BASES_CLIENTES') && (
    <ClientesModule>
        <Button onClick={handleNuevo}>Nuevo</Button>
        <Button onClick={handleEditar}>Editar</Button>
        <Button onClick={handleEliminar}>Eliminar</Button>
    </ClientesModule>
)}

Comunicación con Backend

El sistema tiene dos enfoques diferentes de comunicación según la tecnología:

1. Legacy (PHP SSR) - Peticiones a backend/

⚠️ Enfoque Legacy - No usar en código nuevo

El frontend Legacy hace peticiones a archivos PHP dentro de la carpeta backend/ del mismo proyecto frontend:

javascript
// Petición FETCH mediante API Class
ApiRequest.post('/backend/tesoreria/procesar_retencion',{
        nombre: 'IIBB',
        cuenta: 1234567890,
        tipo: 'P',
        valor: 2.5
});

Estructura Legacy:

frontend/
├── index.php
├── views/
└── backend/              ← Archivos PHP que procesan peticiones
    ├── tesoreria/
    │   └── procesar_retencion.php
    └── ventas/

Problemas de este enfoque:

  • Lógica de negocio mezclada con frontend
  • Difícil de mantener y testear
  • No reutilizable por otros clientes
  • Acoplamiento fuerte

2. Moderno (React) - Axios a API REST

✅ Enfoque Recomendado - Usar en todo código nuevo

El frontend React hace peticiones directas al backend API REST usando Axios:

javascript
// GET - Listar retenciones
const getRetenciones = async () => {
    try {
        const response = await api.get('/tesoreria/boniret');
        return response.data;
    } catch (error) {
        console.error('Error:', error);
        throw error;
    }
};

Estructura Moderna:

bautista-app/          (Frontend React)
├── src/
│   ├── api/
│   │   ├── axios.js   ← Configuración de Axios
│   │   └── tesoreria.js
│   └── components/

bautista-backend/      (Backend API REST)
├── Routes/
├── controller/
├── service/
└── models/

Ventajas de este enfoque:

  • Separación clara frontend/backend
  • Reutilizable por múltiples clientes (web, mobile)
  • Fácil de testear
  • Escalable y mantenible

3. Comparación de Enfoques

AspectoLegacy (backend/)Moderno (Axios + API REST)
Ubicación/frontend/backend/*.phpBackend separado /api/*
ProtocoloPeticiones a archivos PHPHTTP REST JSON
Cliente HTTPjQuery AJAX / fetchAxios
AutenticaciónSesiones PHPJWT tokens
ReutilizaciónSolo webWeb, mobile, integraciones
TestingDifícilFácil (unitario + integración)
Estado⚠️ Legacy - deprecar✅ Actual - usar siempre

Manejo de Respuestas

Success Response:

json
{
  "status": 200,
  "message": "Datos recibidos correctamente.",
  "data": { ... }
}

Error Response:

json
{
  "error": "Mensaje de error descriptivo"
}

Estado de Carga

Mostrar indicadores visuales durante operaciones asíncronas:

javascript
function cargarDatos() {
    mostrarSpinner();

    fetch('/api/...')
        .then(response => response.json())
        .then(data => {
            ocultarSpinner();
            renderizarDatos(data);
        })
        .catch(error => {
            ocultarSpinner();
            mostrarError(error.message);
        });
}

Validación de Datos

Client-Side Validation

Validaciones básicas en el cliente para UX:

javascript
function validarFormulario() {
    let errores = [];

    // Campo requerido
    if (!nombre.value) {
        errores.push('El nombre es requerido');
    }

    // Longitud máxima
    if (nombre.value.length > 15) {
        errores.push('El nombre no puede exceder 15 caracteres');
    }

    // Valor numérico
    if (isNaN(parseFloat(valor.value))) {
        errores.push('El valor debe ser numérico');
    }

    // Mostrar errores
    if (errores.length > 0) {
        mostrarErrores(errores);
        return false;
    }

    return true;
}

Importante: Las validaciones client-side son solo para UX. El backend SIEMPRE debe validar.

Mostrar Errores

Errores inline junto a campos:

html
<div class="form-group">
    <label>Nombre:</label>
    <input type="text" id="nombre" name="nombre" />
    <span class="error-message" id="nombre-error"></span>
</div>

Patrones de UI

Loading States

Mostrar estado de carga:

html
<div class="spinner" id="spinner" style="display: none;">
    <i class="fa fa-spinner fa-spin"></i> Cargando...
</div>

Empty States

Mostrar cuando no hay datos:

html
<div class="empty-state">
    <p>No se encontraron resultados</p>
    <button onclick="nuevaRetencion()">Crear primera retención</button>
</div>

Error States

Mostrar errores de forma clara:

html
<div class="alert alert-error">
    <strong>Error:</strong> No se pudo guardar el registro.
    <button onclick="reintentar()">Reintentar</button>
</div>

Success Messages

Confirmar operaciones exitosas:

html
<div class="alert alert-success">
    <strong>Éxito:</strong> El registro se guardó correctamente.
</div>

Estilos y Temas

CSS Structure

Organización de estilos:

css/
├── base/
│   ├── reset.css
│   ├── typography.css
│   └── variables.css
├── components/
│   ├── buttons.css
│   ├── forms.css
│   ├── tables.css
│   └── modals.css
├── layouts/
│   ├── grid.css
│   └── navigation.css
└── modules/
    ├── ventas.css
    └── tesoreria.css

Responsive Design

Asegurar que las vistas funcionen en diferentes dispositivos:

  • Desktop (1024px+)
  • Tablet (768px - 1023px)
  • Mobile (< 768px)

Migración a SPA (Futuro)

Consideraciones para modernización

Si se decide migrar a un SPA moderno (React, Vue, Angular):

Ventajas:

  • Mejor experiencia de usuario
  • Componentes reutilizables
  • Estado de aplicación centralizado
  • Testing más robusto
  • Desarrollo más ágil

Pasos sugeridos:

  1. Migrar módulo por módulo
  2. Mantener backend API intacto
  3. Coexistencia temporal legacy + SPA
  4. Migración incremental

Stack sugerido:

  • React/Vue/Angular
  • TypeScript
  • State management (Redux/Vuex/NgRx)
  • Routing (React Router/Vue Router)
  • UI Library (Material UI/Vuetify)

Documentar Vistas

Template para vista

Al documentar una vista frontend, incluir:

  1. Ubicación: Ruta en el menú
  2. Propósito: Qué hace la vista
  3. Permisos: Permisos necesarios
  4. Elementos UI: Qué muestra
  5. Interacciones: Qué puede hacer el usuario
  6. API calls: Qué endpoints consume
  7. Validaciones: Qué se valida
  8. Estados: Loading, error, empty, success

Ver: Template de funcionalidad sección Frontend

Referencias


Última actualización: 2025-12-09