33 KiB
33 KiB
ET-FRONTEND-MGN-002-002: Configuración de Empresa
RF Asociado: RF-MGN-002-002 ET Backend: ET-BACKEND-MGN-002-002 Módulo: MGN-002 Complejidad: Baja Story Points: 2 SP (Frontend) Estado: Diseñado Fecha: 2025-11-24
Resumen Técnico
Implementación frontend para configuración de empresa. Incluye componentes React con TypeScript, formularios con validación, integración con API backend, y arquitectura Feature-Sliced Design (FSD).
Stack Tecnológico
- Framework: React 18.x + TypeScript 5.x
- Build Tool: Vite 5.x
- UI Library: Ant Design 5.x (AntD)
- State Management: Zustand + React Query (TanStack Query)
- Routing: React Router 6.x
- Forms: React Hook Form + Zod validation
- HTTP Client: Axios (con interceptors para auth)
- Testing: Vitest + React Testing Library + Playwright
Arquitectura Frontend (FSD)
Estructura basada en Feature-Sliced Design:
src/
├── app/ # App-level config
│ ├── providers/
│ │ └── router.tsx
│ └── styles/
│ └── theme.ts
├── pages/ # Route pages
│ └── ConfiguraciónEmpresaPage/
│ ├── index.tsx
│ └── ConfiguraciónEmpresaPage.tsx
├── widgets/ # Complex UI blocks
│ └── ConfiguraciónEmpresaTable/
│ ├── ui/
│ │ └── ConfiguraciónEmpresaTable.tsx
│ └── index.ts
├── features/ # User interactions
│ ├── createConfiguraciónEmpresa/
│ │ ├── ui/
│ │ │ └── CreateConfiguraciónEmpresaForm.tsx
│ │ ├── model/
│ │ │ └── useConfiguraciónEmpresaActions.ts
│ │ └── index.ts
│ ├── updateConfiguraciónEmpresa/
│ └── deleteConfiguraciónEmpresa/
├── entities/ # Business entities
│ └── configuraciónEmpresa/
│ ├── model/
│ │ ├── types.ts
│ │ ├── schemas.ts
│ │ └── configuraciónEmpresa.store.ts
│ ├── api/
│ │ ├── configuraciónEmpresa.api.ts
│ │ └── configuraciónEmpresa.queries.ts
│ ├── ui/
│ │ └── ConfiguraciónEmpresaCard.tsx
│ └── index.ts
└── shared/ # Shared code
├── ui/ # UI kit
│ ├── Button/
│ ├── Modal/
│ └── Table/
├── api/
│ └── client.ts
└── lib/
└── utils.ts
Rutas
// src/app/routes/mgn-002.routes.tsx
export const ConfiguraciónEmpresaRoutes = {
list: '/mgn-002/configuraciónEmpresa',
create: '/mgn-002/configuraciónEmpresa/create',
edit: '/mgn-002/configuraciónEmpresa/:id/edit',
view: '/mgn-002/configuraciónEmpresa/:id',
};
// Integración en Router
<Route path="/mgn-002">
<Route path="configuraciónEmpresa" element={<ConfiguraciónEmpresaPage />} />
<Route path="configuraciónEmpresa/create" element={<CreateConfiguraciónEmpresaPage />} />
<Route path="configuraciónEmpresa/:id/edit" element={<EditConfiguraciónEmpresaPage />} />
<Route path="configuraciónEmpresa/:id" element={<ViewConfiguraciónEmpresaPage />} />
</Route>
Types / Interfaces
// src/entities/configuraciónEmpresa/model/types.ts
export interface ConfiguraciónEmpresa {
id: string;
tenantId: string;
name: string;
code?: string;
createdAt: string;
updatedAt?: string;
deletedAt?: string;
}
export interface CreateConfiguraciónEmpresaDto {
name: string;
code?: string;
}
export type UpdateConfiguraciónEmpresaDto = Partial<CreateConfiguraciónEmpresaDto>;
export interface ConfiguraciónEmpresaFilters {
search?: string;
page?: number;
limit?: number;
sortBy?: string;
sortOrder?: 'asc' | 'desc';
}
export interface ConfiguraciónEmpresaListResponse {
data: ConfiguraciónEmpresa[];
meta: {
page: number;
limit: number;
total: number;
totalPages: number;
};
}
Schemas de Validación (Zod)
// src/entities/configuraciónEmpresa/model/schemas.ts
import { z } from 'zod';
export const createConfiguraciónEmpresaSchema = z.object({
name: z.string()
.min(3, 'El nombre debe tener al menos 3 caracteres')
.max(255, 'El nombre no puede exceder 255 caracteres'),
code: z.string()
.min(2, 'El código debe tener al menos 2 caracteres')
.max(50, 'El código no puede exceder 50 caracteres')
.optional(),
});
export const updateConfiguraciónEmpresaSchema = createConfiguraciónEmpresaSchema.partial();
export type CreateConfiguraciónEmpresaFormData = z.infer<typeof createConfiguraciónEmpresaSchema>;
export type UpdateConfiguraciónEmpresaFormData = z.infer<typeof updateConfiguraciónEmpresaSchema>;
// Validación personalizada (ejemplo)
export const validateConfiguraciónEmpresaCode = (code: string): boolean => {
return /^[A-Z0-9-]+$/.test(code);
};
API Client
// src/entities/configuraciónEmpresa/api/configuraciónEmpresa.api.ts
import { apiClient } from '@shared/api/client';
import type {
ConfiguraciónEmpresa,
CreateConfiguraciónEmpresaDto,
UpdateConfiguraciónEmpresaDto,
ConfiguraciónEmpresaFilters,
ConfiguraciónEmpresaListResponse
} from '../model/types';
const BASE_URL = '/api/v1/companies/:id/settings';
export const configuraciónEmpresaApi = {
getAll: async (filters?: ConfiguraciónEmpresaFilters): Promise<ConfiguraciónEmpresaListResponse> => {
const { data } = await apiClient.get<ConfiguraciónEmpresaListResponse>(BASE_URL, {
params: filters,
});
return data;
},
getById: async (id: string): Promise<ConfiguraciónEmpresa> => {
const { data } = await apiClient.get<{ data: ConfiguraciónEmpresa }>(`${BASE_URL}/${id}`);
return data.data;
},
create: async (dto: CreateConfiguraciónEmpresaDto): Promise<ConfiguraciónEmpresa> => {
const { data } = await apiClient.post<{ data: ConfiguraciónEmpresa }>(BASE_URL, dto);
return data.data;
},
update: async (id: string, dto: UpdateConfiguraciónEmpresaDto): Promise<ConfiguraciónEmpresa> => {
const { data } = await apiClient.put<{ data: ConfiguraciónEmpresa }>(`${BASE_URL}/${id}`, dto);
return data.data;
},
delete: async (id: string): Promise<void> => {
await apiClient.delete(`${BASE_URL}/${id}`);
},
};
// Configuración de Axios client
// src/shared/api/client.ts
import axios from 'axios';
import { message } from 'antd';
export const apiClient = axios.create({
baseURL: import.meta.env.VITE_API_URL || 'http://localhost:3000',
timeout: 30000,
});
// Request interceptor: agregar auth token
apiClient.interceptors.request.use(
(config) => {
const token = localStorage.getItem('accessToken');
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
},
(error) => Promise.reject(error)
);
// Response interceptor: manejar errores globales
apiClient.interceptors.response.use(
(response) => response,
(error) => {
const { response } = error;
if (response?.status === 401) {
// Token expirado: redirigir a login
localStorage.removeItem('accessToken');
window.location.href = '/login';
} else if (response?.status === 403) {
message.error('No tienes permisos para realizar esta acción');
} else if (response?.status === 500) {
message.error('Error interno del servidor');
}
return Promise.reject(error);
}
);
State Management (Zustand + React Query)
Zustand Store (estado local UI)
// src/entities/configuraciónEmpresa/model/configuraciónEmpresa.store.ts
import { create } from 'zustand';
import { ConfiguraciónEmpresa } from './types';
interface ConfiguraciónEmpresaStore {
selectedConfiguraciónEmpresa: ConfiguraciónEmpresa | null;
isModalOpen: boolean;
modalMode: 'create' | 'edit' | 'view' | null;
setSelectedConfiguraciónEmpresa: (entity: ConfiguraciónEmpresa | null) => void;
openModal: (mode: 'create' | 'edit' | 'view', entity?: ConfiguraciónEmpresa) => void;
closeModal: () => void;
}
export const useConfiguraciónEmpresaStore = create<ConfiguraciónEmpresaStore>((set) => ({
selectedConfiguraciónEmpresa: null,
isModalOpen: false,
modalMode: null,
setSelectedConfiguraciónEmpresa: (entity) => set({ selectedConfiguraciónEmpresa: entity }),
openModal: (mode, entity) => set({
isModalOpen: true,
modalMode: mode,
selectedConfiguraciónEmpresa: entity || null,
}),
closeModal: () => set({
isModalOpen: false,
modalMode: null,
selectedConfiguraciónEmpresa: null,
}),
}));
React Query Hooks (servidor state)
// src/entities/configuraciónEmpresa/api/configuraciónEmpresa.queries.ts
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { message } from 'antd';
import { configuraciónEmpresaApi } from './configuraciónEmpresa.api';
import type { CreateConfiguraciónEmpresaDto, UpdateConfiguraciónEmpresaDto, ConfiguraciónEmpresaFilters } from '../model/types';
const QUERY_KEY = 'configuraciónEmpresa';
// Query: obtener lista
export const useConfiguraciónEmpresas = (filters?: ConfiguraciónEmpresaFilters) => {
return useQuery({
queryKey: [QUERY_KEY, filters],
queryFn: () => configuraciónEmpresaApi.getAll(filters),
staleTime: 5 * 60 * 1000, // 5 minutos
});
};
// Query: obtener por ID
export const useConfiguraciónEmpresa = (id: string) => {
return useQuery({
queryKey: [QUERY_KEY, id],
queryFn: () => configuraciónEmpresaApi.getById(id),
enabled: !!id,
});
};
// Mutation: crear
export const useCreateConfiguraciónEmpresa = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (dto: CreateConfiguraciónEmpresaDto) => configuraciónEmpresaApi.create(dto),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: [QUERY_KEY] });
message.success('ConfiguraciónEmpresa creado exitosamente');
},
onError: (error: any) => {
const errorMsg = error.response?.data?.message || 'Error al crear configuraciónempresa';
message.error(errorMsg);
},
});
};
// Mutation: actualizar
export const useUpdateConfiguraciónEmpresa = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ id, dto }: { id: string; dto: UpdateConfiguraciónEmpresaDto }) =>
configuraciónEmpresaApi.update(id, dto),
onSuccess: (data) => {
queryClient.invalidateQueries({ queryKey: [QUERY_KEY] });
queryClient.setQueryData([QUERY_KEY, data.id], data);
message.success('ConfiguraciónEmpresa actualizado exitosamente');
},
onError: (error: any) => {
const errorMsg = error.response?.data?.message || 'Error al actualizar configuraciónempresa';
message.error(errorMsg);
},
});
};
// Mutation: eliminar
export const useDeleteConfiguraciónEmpresa = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (id: string) => configuraciónEmpresaApi.delete(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: [QUERY_KEY] });
message.success('ConfiguraciónEmpresa eliminado exitosamente');
},
onError: (error: any) => {
const errorMsg = error.response?.data?.message || 'Error al eliminar configuraciónempresa';
message.error(errorMsg);
},
});
};
Components UI
Tabla de Listado
// src/widgets/ConfiguraciónEmpresaTable/ui/ConfiguraciónEmpresaTable.tsx
import React, { useState } from 'react';
import { Table, Button, Space, Input, Modal } from 'antd';
import { EditOutlined, DeleteOutlined, EyeOutlined, PlusOutlined } from '@ant-design/icons';
import type { ColumnsType } from 'antd/es/table';
import { useConfiguraciónEmpresas, useDeleteConfiguraciónEmpresa } from '@entities/configuraciónEmpresa';
import { useConfiguraciónEmpresaStore } from '@entities/configuraciónEmpresa';
import type { ConfiguraciónEmpresa, ConfiguraciónEmpresaFilters } from '@entities/configuraciónEmpresa';
const { Search } = Input;
export const ConfiguraciónEmpresaTable: React.FC = () => {
const [filters, setFilters] = useState<ConfiguraciónEmpresaFilters>({
page: 1,
limit: 20,
sortBy: 'createdAt',
sortOrder: 'desc',
});
const { data, isLoading } = useConfiguraciónEmpresas(filters);
const deleteMutation = useDeleteConfiguraciónEmpresa();
const { openModal } = useConfiguraciónEmpresaStore();
const handleSearch = (value: string) => {
setFilters((prev) => ({ ...prev, search: value, page: 1 }));
};
const handleTableChange = (pagination: any, filters: any, sorter: any) => {
setFilters((prev) => ({
...prev,
page: pagination.current,
limit: pagination.pageSize,
sortBy: sorter.field || 'createdAt',
sortOrder: sorter.order === 'ascend' ? 'asc' : 'desc',
}));
};
const handleDelete = (id: string, name: string) => {
Modal.confirm({
title: '¿Confirmar eliminación?',
content: `¿Está seguro de eliminar "${name}"? Esta acción no se puede deshacer.`,
okText: 'Eliminar',
okType: 'danger',
cancelText: 'Cancelar',
onOk: () => deleteMutation.mutate(id),
});
};
const columns: ColumnsType<ConfiguraciónEmpresa> = [
{
title: 'Nombre',
dataIndex: 'name',
key: 'name',
sorter: true,
width: '40%',
},
{
title: 'Código',
dataIndex: 'code',
key: 'code',
width: '20%',
},
{
title: 'Fecha Creación',
dataIndex: 'createdAt',
key: 'createdAt',
sorter: true,
width: '20%',
render: (date: string) => new Date(date).toLocaleDateString('es-ES'),
},
{
title: 'Acciones',
key: 'actions',
width: '20%',
render: (_, record) => (
<Space>
<Button
icon={<EyeOutlined />}
onClick={() => openModal('view', record)}
title="Ver detalles"
/>
<Button
icon={<EditOutlined />}
onClick={() => openModal('edit', record)}
title="Editar"
/>
<Button
icon={<DeleteOutlined />}
danger
onClick={() => handleDelete(record.id, record.name)}
title="Eliminar"
/>
</Space>
),
},
];
return (
<div>
<Space style={{ marginBottom: 16, width: '100%', justifyContent: 'space-between' }}>
<Search
placeholder="Buscar por nombre o código"
onSearch={handleSearch}
style={{ width: 300 }}
allowClear
/>
<Button
type="primary"
icon={<PlusOutlined />}
onClick={() => openModal('create')}
>
Crear ConfiguraciónEmpresa
</Button>
</Space>
<Table
columns={columns}
dataSource={data?.data || []}
loading={isLoading || deleteMutation.isPending}
rowKey="id"
pagination={{
current: filters.page,
pageSize: filters.limit,
total: data?.meta.total || 0,
showSizeChanger: true,
showTotal: (total) => `Total: ${total} registros`,
}}
onChange={handleTableChange}
/>
</div>
);
};
Formulario de Creación/Edición
// src/features/createConfiguraciónEmpresa/ui/CreateConfiguraciónEmpresaForm.tsx
import React, { useEffect } from 'react';
import { Form, Input, Button, Space } from 'antd';
import { useForm, Controller } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import {
createConfiguraciónEmpresaSchema,
updateConfiguraciónEmpresaSchema,
type CreateConfiguraciónEmpresaFormData
} from '@entities/configuraciónEmpresa';
import { useCreateConfiguraciónEmpresa, useUpdateConfiguraciónEmpresa } from '@entities/configuraciónEmpresa';
import type { ConfiguraciónEmpresa } from '@entities/configuraciónEmpresa';
interface ConfiguraciónEmpresaFormProps {
mode: 'create' | 'edit';
initialData?: ConfiguraciónEmpresa;
onSuccess?: () => void;
}
export const ConfiguraciónEmpresaForm: React.FC<ConfiguraciónEmpresaFormProps> = ({
mode,
initialData,
onSuccess,
}) => {
const isEditMode = mode === 'edit';
const schema = isEditMode ? updateConfiguraciónEmpresaSchema : createConfiguraciónEmpresaSchema;
const { control, handleSubmit, formState: { errors }, reset } = useForm<CreateConfiguraciónEmpresaFormData>({
resolver: zodResolver(schema),
defaultValues: initialData || {
name: '',
code: '',
},
});
const createMutation = useCreateConfiguraciónEmpresa();
const updateMutation = useUpdateConfiguraciónEmpresa();
const mutation = isEditMode ? updateMutation : createMutation;
useEffect(() => {
if (initialData) {
reset(initialData);
}
}, [initialData, reset]);
const onSubmit = (data: CreateConfiguraciónEmpresaFormData) => {
if (isEditMode && initialData) {
updateMutation.mutate(
{ id: initialData.id, dto: data },
{ onSuccess: () => onSuccess?.() }
);
} else {
createMutation.mutate(data, {
onSuccess: () => {
reset();
onSuccess?.();
},
});
}
};
return (
<Form layout="vertical" onFinish={handleSubmit(onSubmit)}>
<Controller
name="name"
control={control}
render={({ field }) => (
<Form.Item
label="Nombre"
validateStatus={errors.name ? 'error' : ''}
help={errors.name?.message}
required
>
<Input {...field} placeholder="Ingrese el nombre" />
</Form.Item>
)}
/>
<Controller
name="code"
control={control}
render={({ field }) => (
<Form.Item
label="Código"
validateStatus={errors.code ? 'error' : ''}
help={errors.code?.message}
>
<Input {...field} placeholder="Código único (opcional)" />
</Form.Item>
)}
/>
<Form.Item>
<Space>
<Button
type="primary"
htmlType="submit"
loading={mutation.isPending}
>
{isEditMode ? 'Actualizar' : 'Crear'}
</Button>
<Button onClick={() => reset()}>
Limpiar
</Button>
</Space>
</Form.Item>
</Form>
);
};
Modal Wrapper
// src/features/createConfiguraciónEmpresa/ui/CreateConfiguraciónEmpresaModal.tsx
import React from 'react';
import { Modal } from 'antd';
import { useConfiguraciónEmpresaStore } from '@entities/configuraciónEmpresa';
import { ConfiguraciónEmpresaForm } from './CreateConfiguraciónEmpresaForm';
export const ConfiguraciónEmpresaModal: React.FC = () => {
const { isModalOpen, modalMode, selectedConfiguraciónEmpresa, closeModal } = useConfiguraciónEmpresaStore();
const title = {
create: 'Crear ConfiguraciónEmpresa',
edit: 'Editar ConfiguraciónEmpresa',
view: 'Ver ConfiguraciónEmpresa',
}[modalMode || 'create'];
return (
<Modal
title={title}
open={isModalOpen}
onCancel={closeModal}
footer={null}
width={600}
destroyOnClose
>
{modalMode !== 'view' ? (
<ConfiguraciónEmpresaForm
mode={modalMode === 'edit' ? 'edit' : 'create'}
initialData={selectedConfiguraciónEmpresa || undefined}
onSuccess={closeModal}
/>
) : (
<div>
<p><strong>Nombre:</strong> {selectedConfiguraciónEmpresa?.name}</p>
<p><strong>Código:</strong> {selectedConfiguraciónEmpresa?.code || 'N/A'}</p>
<p><strong>Creado:</strong> {new Date(selectedConfiguraciónEmpresa?.createdAt || '').toLocaleString('es-ES')}</p>
</div>
)}
</Modal>
);
};
Página Principal
// src/pages/ConfiguraciónEmpresaPage/ConfiguraciónEmpresaPage.tsx
import React from 'react';
import { Card } from 'antd';
import { ConfiguraciónEmpresaTable } from '@widgets/ConfiguraciónEmpresaTable';
import { ConfiguraciónEmpresaModal } from '@features/createConfiguraciónEmpresa';
export const ConfiguraciónEmpresaPage: React.FC = () => {
return (
<div style={{ padding: 24 }}>
<Card title="Configuración de Empresa">
<ConfiguraciónEmpresaTable />
</Card>
<ConfiguraciónEmpresaModal />
</div>
);
};
Validaciones del Cliente
Validación en Tiempo Real
// Las validaciones Zod se ejecutan automáticamente con React Hook Form
// Validación custom adicional (ejemplo)
const validateUniqueConfiguraciónEmpresaCode = async (code: string): Promise<boolean> => {
try {
const { data } = await configuraciónEmpresaApi.getAll({ search: code });
return data.data.length === 0;
} catch {
return false;
}
};
// Uso en formulario
<Controller
name="code"
control={control}
rules={{
validate: async (value) => {
if (value && !(await validateUniqueConfiguraciónEmpresaCode(value))) {
return 'Este código ya existe';
}
return true;
},
}}
render={...}
/>
Mensajes de Error
- Español claro y conciso
- Sugerencias de corrección cuando sea posible
- Highlight visual de campos con error (Ant Design
validateStatus)
UX/UI
Diseño Visual
- Layout: Ant Design Pro Layout (Header + Sidebar + Content)
- Colores:
- Primary:
#1890ff(Ant Design default) - Success:
#52c41a - Warning:
#faad14 - Error:
#f5222d
- Primary:
- Tipografía:
- Headings: Inter font family
- Body: Roboto font family
- Iconos: Ant Design Icons
Feedback al Usuario
- Loading States: Spinners en botones (
Button loading={true}) y tablas (Table loading={true}) - Success Messages:
message.success('Operación exitosa') - Error Messages:
message.error('Error: detalles...') - Confirmations:
Modal.confirm()para acciones destructivas (delete) - Progress:
Progresspara operaciones largas
Responsiveness
- Desktop (1200px+): Tabla completa con todas las columnas
- Tablet (768-1199px): Tabla adaptada, algunas columnas ocultas
- Mobile (<768px): Reemplazar tabla por cards (
<List>de Ant Design)
// Ejemplo responsive
import { useMediaQuery } from '@shared/hooks/useMediaQuery';
const isMobile = useMediaQuery('(max-width: 768px)');
return isMobile ? (
<List
dataSource={data?.data}
renderItem={(item) => (
<List.Item>
<Card>{/* Card layout */}</Card>
</List.Item>
)}
/>
) : (
<Table ... />
);
Permisos (RBAC en UI)
// src/shared/hooks/usePermissions.ts
import { useAuth } from '@modules/auth';
export const usePermissions = () => {
const { user } = useAuth();
const can = (permission: string): boolean => {
return user?.permissions?.includes(permission) ?? false;
};
const hasRole = (role: string): boolean => {
return user?.roles?.includes(role) ?? false;
};
return { can, hasRole };
};
// Uso en componentes
import { usePermissions } from '@shared/hooks/usePermissions';
const { can } = usePermissions();
{can('mgn-002.configuraciónEmpresa.create') && (
<Button type="primary" onClick={handleCreate}>
Crear ConfiguraciónEmpresa
</Button>
)}
{can('mgn-002.configuraciónEmpresa.delete') && (
<Button danger onClick={handleDelete}>
Eliminar
</Button>
)}
Testing
Component Tests (Vitest + React Testing Library)
// src/widgets/ConfiguraciónEmpresaTable/ui/ConfiguraciónEmpresaTable.test.tsx
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { ConfiguraciónEmpresaTable } from './ConfiguraciónEmpresaTable';
import { configuraciónEmpresaApi } from '@entities/configuraciónEmpresa';
// Mock API
vi.mock('@entities/configuraciónEmpresa', () => ({
configuraciónEmpresaApi: {
getAll: vi.fn(),
delete: vi.fn(),
},
}));
describe('ConfiguraciónEmpresaTable', () => {
const queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
},
});
const wrapper = ({ children }) => (
<QueryClientProvider client={queryClient}>
{children}
</QueryClientProvider>
);
beforeEach(() => {
vi.clearAllMocks();
});
it('should render table with data', async () => {
const mockData = {
data: [
{ id: '1', name: 'Test ConfiguraciónEmpresa 1', code: 'TEST1', createdAt: '2025-11-24' },
{ id: '2', name: 'Test ConfiguraciónEmpresa 2', code: 'TEST2', createdAt: '2025-11-24' },
],
meta: { page: 1, limit: 20, total: 2, totalPages: 1 },
};
vi.mocked(configuraciónEmpresaApi.getAll).mockResolvedValue(mockData);
render(<ConfiguraciónEmpresaTable />, { wrapper });
await waitFor(() => {
expect(screen.getByText('Test ConfiguraciónEmpresa 1')).toBeInTheDocument();
expect(screen.getByText('Test ConfiguraciónEmpresa 2')).toBeInTheDocument();
});
});
it('should call delete mutation on delete button click', async () => {
const user = userEvent.setup();
const mockData = {
data: [{ id: '1', name: 'To Delete', code: 'DEL', createdAt: '2025-11-24' }],
meta: { page: 1, limit: 20, total: 1, totalPages: 1 },
};
vi.mocked(configuraciónEmpresaApi.getAll).mockResolvedValue(mockData);
vi.mocked(configuraciónEmpresaApi.delete).mockResolvedValue();
render(<ConfiguraciónEmpresaTable />, { wrapper });
await waitFor(() => {
expect(screen.getByText('To Delete')).toBeInTheDocument();
});
const deleteBtn = screen.getByTitle('Eliminar');
await user.click(deleteBtn);
// Confirmar modal
const confirmBtn = screen.getByText('Eliminar');
await user.click(confirmBtn);
await waitFor(() => {
expect(configuraciónEmpresaApi.delete).toHaveBeenCalledWith('1');
});
});
it('should filter data on search', async () => {
const user = userEvent.setup();
vi.mocked(configuraciónEmpresaApi.getAll).mockResolvedValue({
data: [],
meta: { page: 1, limit: 20, total: 0, totalPages: 0 },
});
render(<ConfiguraciónEmpresaTable />, { wrapper });
const searchInput = screen.getByPlaceholderText('Buscar por nombre o código');
await user.type(searchInput, 'test');
await user.keyboard('{Enter}');
await waitFor(() => {
expect(configuraciónEmpresaApi.getAll).toHaveBeenCalledWith(
expect.objectContaining({ search: 'test' })
);
});
});
});
E2E Tests (Playwright)
// e2e/configuraciónEmpresa.spec.ts
import { test, expect } from '@playwright/test';
test.describe('ConfiguraciónEmpresa Management', () => {
test.beforeEach(async ({ page }) => {
// Login
await page.goto('/login');
await page.fill('[name="email"]', 'test@example.com');
await page.fill('[name="password"]', 'Test1234!');
await page.click('button[type="submit"]');
await page.waitForURL('/mgn-002/configuraciónEmpresa');
});
test('should create new configuraciónEmpresa', async ({ page }) => {
await page.goto('/mgn-002/configuraciónEmpresa');
// Click create button
await page.click('text=Crear ConfiguraciónEmpresa');
// Fill form
await page.fill('[name="name"]', 'E2E Test ConfiguraciónEmpresa');
await page.fill('[name="code"]', 'E2E001');
// Submit
await page.click('button[type="submit"]');
// Verify success message
await expect(page.locator('.ant-message-success')).toContainText('creado exitosamente');
// Verify in table
await expect(page.locator('table')).toContainText('E2E Test ConfiguraciónEmpresa');
});
test('should edit existing configuraciónEmpresa', async ({ page }) => {
await page.goto('/mgn-002/configuraciónEmpresa');
// Click edit on first row
await page.click('table tbody tr:first-child button[title="Editar"]');
// Update name
await page.fill('[name="name"]', 'Updated Name');
// Submit
await page.click('button[type="submit"]');
// Verify success
await expect(page.locator('.ant-message-success')).toContainText('actualizado exitosamente');
});
test('should delete configuraciónEmpresa with confirmation', async ({ page }) => {
await page.goto('/mgn-002/configuraciónEmpresa');
// Click delete on first row
await page.click('table tbody tr:first-child button[title="Eliminar"]');
// Confirm deletion
await page.click('.ant-modal-confirm button.ant-btn-dangerous');
// Verify success
await expect(page.locator('.ant-message-success')).toContainText('eliminado exitosamente');
});
test('should validate required fields', async ({ page }) => {
await page.goto('/mgn-002/configuraciónEmpresa');
// Click create button
await page.click('text=Crear ConfiguraciónEmpresa');
// Try to submit empty form
await page.click('button[type="submit"]');
// Verify validation errors
await expect(page.locator('.ant-form-item-explain-error')).toContainText('al menos 3 caracteres');
});
test('should search and filter configuraciónEmpresas', async ({ page }) => {
await page.goto('/mgn-002/configuraciónEmpresa');
// Search
await page.fill('[placeholder="Buscar por nombre o código"]', 'test');
await page.keyboard.press('Enter');
// Verify API call (check network tab or wait for results)
await page.waitForTimeout(500);
// Results should be filtered
const rows = page.locator('table tbody tr');
await expect(rows.first()).toBeVisible();
});
});
Performance
Optimizaciones React
// 1. Lazy loading de páginas
const ConfiguraciónEmpresaPage = React.lazy(() => import('@pages/ConfiguraciónEmpresaPage'));
// 2. Memo para componentes pesados
export const ConfiguraciónEmpresaCard = React.memo<ConfiguraciónEmpresaCardProps>(({ data }) => {
// ...
});
// 3. useMemo para cálculos pesados
const filteredData = useMemo(() => {
return data?.data.filter((item) => item.status === 'active');
}, [data]);
// 4. useCallback para funciones pasadas como props
const handleEdit = useCallback((id: string) => {
openModal('edit', data.find((item) => item.id === id));
}, [data, openModal]);
Virtualización para Listas Largas
// Para tablas con >100 rows
import { FixedSizeList } from 'react-window';
<FixedSizeList
height={600}
itemCount={data?.data.length || 0}
itemSize={50}
width={'100%'}
>
{({ index, style }) => (
<div style={style}>{/* Row content */}</div>
)}
</FixedSizeList>
Debounce en Búsqueda
import { useDebouncedCallback } from 'use-debounce';
const debouncedSearch = useDebouncedCallback(
(value: string) => {
setFilters((prev) => ({ ...prev, search: value, page: 1 }));
},
300
);
<Search onChange={(e) => debouncedSearch(e.target.value)} />
Bundle Size
- Code splitting: Lazy loading de páginas (
React.lazy()) - Tree shaking: Importar solo lo necesario de Ant Design
- Chunk optimization: Vite automático
- Target bundle size: <200 KB por chunk
Referencias
Dependencias
Módulos Frontend
AuthModule- Autenticación y autorizaciónSharedModule- Componentes y utilities compartidos
RF Bloqueantes
- RF-MGN-001-001 (Autenticación de Usuarios)
- RF-MGN-002-002 Backend completado
Notas de Implementación
- Crear estructura FSD:
entities/configuraciónEmpresa,features/createConfiguraciónEmpresa,widgets/ConfiguraciónEmpresaTable - Definir types e interfaces en
entities/configuraciónEmpresa/model/types.ts - Crear schemas de validación Zod
- Implementar API client con axios
- Crear React Query hooks (queries + mutations)
- Implementar Zustand store para UI state
- Crear componentes UI (Table, Form, Modal)
- Implementar rutas en React Router
- Crear tests (componentes + e2e)
- Validar responsiveness (desktop + tablet + mobile)
- Validar con criterios de aceptación del RF
- Code review por Tech Lead
- QA testing
Estimación
- Frontend Development: 2 SP
- Testing (Unit + e2e): 1 SP
- Code Review + QA: 1 SP
- Total: 5 SP
Documento generado: 2025-11-24 Versión: 1.0 Estado: Diseñado Próximo paso: Implementación