erp-core/docs/04-modelado/especificaciones-tecnicas/frontend/mgn-010/ET-FRONTEND-MGN-010-002-departamentos-y-puestos.md

32 KiB

ET-FRONTEND-MGN-010-002: Departamentos y Puestos

RF Asociado: RF-MGN-010-002 ET Backend: ET-BACKEND-MGN-010-002 Módulo: MGN-010 Complejidad: Baja Story Points: 1 SP (Frontend) Estado: Diseñado Fecha: 2025-11-24

Resumen Técnico

Implementación frontend para departamentos y puestos. 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
│   └── DepartamentosPuestosPage/
│       ├── index.tsx
│       └── DepartamentosPuestosPage.tsx
├── widgets/                      # Complex UI blocks
│   └── DepartamentosPuestosTable/
│       ├── ui/
│       │   └── DepartamentosPuestosTable.tsx
│       └── index.ts
├── features/                     # User interactions
│   ├── createDepartamentosPuestos/
│   │   ├── ui/
│   │   │   └── CreateDepartamentosPuestosForm.tsx
│   │   ├── model/
│   │   │   └── useDepartamentosPuestosActions.ts
│   │   └── index.ts
│   ├── updateDepartamentosPuestos/
│   └── deleteDepartamentosPuestos/
├── entities/                     # Business entities
│   └── departamentosPuestos/
│       ├── model/
│       │   ├── types.ts
│       │   ├── schemas.ts
│       │   └── departamentosPuestos.store.ts
│       ├── api/
│       │   ├── departamentosPuestos.api.ts
│       │   └── departamentosPuestos.queries.ts
│       ├── ui/
│       │   └── DepartamentosPuestosCard.tsx
│       └── index.ts
└── shared/                       # Shared code
    ├── ui/                       # UI kit
    │   ├── Button/
    │   ├── Modal/
    │   └── Table/
    ├── api/
    │   └── client.ts
    └── lib/
        └── utils.ts

Rutas

// src/app/routes/mgn-010.routes.tsx
export const DepartamentosPuestosRoutes = {
  list: '/mgn-010/departamentosPuestos',
  create: '/mgn-010/departamentosPuestos/create',
  edit: '/mgn-010/departamentosPuestos/:id/edit',
  view: '/mgn-010/departamentosPuestos/:id',
};

// Integración en Router
<Route path="/mgn-010">
  <Route path="departamentosPuestos" element={<DepartamentosPuestosPage />} />
  <Route path="departamentosPuestos/create" element={<CreateDepartamentosPuestosPage />} />
  <Route path="departamentosPuestos/:id/edit" element={<EditDepartamentosPuestosPage />} />
  <Route path="departamentosPuestos/:id" element={<ViewDepartamentosPuestosPage />} />
</Route>

Types / Interfaces

// src/entities/departamentosPuestos/model/types.ts

export interface DepartamentosPuestos {
  id: string;
  tenantId: string;
  name: string;
  code?: string;
  createdAt: string;
  updatedAt?: string;
  deletedAt?: string;
}

export interface CreateDepartamentosPuestosDto {
  name: string;
  code?: string;
}

export type UpdateDepartamentosPuestosDto = Partial<CreateDepartamentosPuestosDto>;

export interface DepartamentosPuestosFilters {
  search?: string;
  page?: number;
  limit?: number;
  sortBy?: string;
  sortOrder?: 'asc' | 'desc';
}

export interface DepartamentosPuestosListResponse {
  data: DepartamentosPuestos[];
  meta: {
    page: number;
    limit: number;
    total: number;
    totalPages: number;
  };
}

Schemas de Validación (Zod)

// src/entities/departamentosPuestos/model/schemas.ts
import { z } from 'zod';

export const createDepartamentosPuestosSchema = 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 updateDepartamentosPuestosSchema = createDepartamentosPuestosSchema.partial();

export type CreateDepartamentosPuestosFormData = z.infer<typeof createDepartamentosPuestosSchema>;
export type UpdateDepartamentosPuestosFormData = z.infer<typeof updateDepartamentosPuestosSchema>;

// Validación personalizada (ejemplo)
export const validateDepartamentosPuestosCode = (code: string): boolean => {
  return /^[A-Z0-9-]+$/.test(code);
};

API Client

// src/entities/departamentosPuestos/api/departamentosPuestos.api.ts
import { apiClient } from '@shared/api/client';
import type {
  DepartamentosPuestos,
  CreateDepartamentosPuestosDto,
  UpdateDepartamentosPuestosDto,
  DepartamentosPuestosFilters,
  DepartamentosPuestosListResponse
} from '../model/types';

const BASE_URL = '/api/v1/hr/departments';

export const departamentosPuestosApi = {
  getAll: async (filters?: DepartamentosPuestosFilters): Promise<DepartamentosPuestosListResponse> => {
    const { data } = await apiClient.get<DepartamentosPuestosListResponse>(BASE_URL, {
      params: filters,
    });
    return data;
  },

  getById: async (id: string): Promise<DepartamentosPuestos> => {
    const { data } = await apiClient.get<{ data: DepartamentosPuestos }>(`${BASE_URL}/${id}`);
    return data.data;
  },

  create: async (dto: CreateDepartamentosPuestosDto): Promise<DepartamentosPuestos> => {
    const { data } = await apiClient.post<{ data: DepartamentosPuestos }>(BASE_URL, dto);
    return data.data;
  },

  update: async (id: string, dto: UpdateDepartamentosPuestosDto): Promise<DepartamentosPuestos> => {
    const { data } = await apiClient.put<{ data: DepartamentosPuestos }>(`${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/departamentosPuestos/model/departamentosPuestos.store.ts
import { create } from 'zustand';
import { DepartamentosPuestos } from './types';

interface DepartamentosPuestosStore {
  selectedDepartamentosPuestos: DepartamentosPuestos | null;
  isModalOpen: boolean;
  modalMode: 'create' | 'edit' | 'view' | null;

  setSelectedDepartamentosPuestos: (entity: DepartamentosPuestos | null) => void;
  openModal: (mode: 'create' | 'edit' | 'view', entity?: DepartamentosPuestos) => void;
  closeModal: () => void;
}

export const useDepartamentosPuestosStore = create<DepartamentosPuestosStore>((set) => ({
  selectedDepartamentosPuestos: null,
  isModalOpen: false,
  modalMode: null,

  setSelectedDepartamentosPuestos: (entity) => set({ selectedDepartamentosPuestos: entity }),

  openModal: (mode, entity) => set({
    isModalOpen: true,
    modalMode: mode,
    selectedDepartamentosPuestos: entity || null,
  }),

  closeModal: () => set({
    isModalOpen: false,
    modalMode: null,
    selectedDepartamentosPuestos: null,
  }),
}));

React Query Hooks (servidor state)

// src/entities/departamentosPuestos/api/departamentosPuestos.queries.ts
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { message } from 'antd';
import { departamentosPuestosApi } from './departamentosPuestos.api';
import type { CreateDepartamentosPuestosDto, UpdateDepartamentosPuestosDto, DepartamentosPuestosFilters } from '../model/types';

const QUERY_KEY = 'departamentosPuestos';

// Query: obtener lista
export const useDepartamentosPuestoss = (filters?: DepartamentosPuestosFilters) => {
  return useQuery({
    queryKey: [QUERY_KEY, filters],
    queryFn: () => departamentosPuestosApi.getAll(filters),
    staleTime: 5 * 60 * 1000, // 5 minutos
  });
};

// Query: obtener por ID
export const useDepartamentosPuestos = (id: string) => {
  return useQuery({
    queryKey: [QUERY_KEY, id],
    queryFn: () => departamentosPuestosApi.getById(id),
    enabled: !!id,
  });
};

// Mutation: crear
export const useCreateDepartamentosPuestos = () => {
  const queryClient = useQueryClient();

  return useMutation({
    mutationFn: (dto: CreateDepartamentosPuestosDto) => departamentosPuestosApi.create(dto),
    onSuccess: () => {
      queryClient.invalidateQueries({ queryKey: [QUERY_KEY] });
      message.success('DepartamentosPuestos creado exitosamente');
    },
    onError: (error: any) => {
      const errorMsg = error.response?.data?.message || 'Error al crear departamentospuestos';
      message.error(errorMsg);
    },
  });
};

// Mutation: actualizar
export const useUpdateDepartamentosPuestos = () => {
  const queryClient = useQueryClient();

  return useMutation({
    mutationFn: ({ id, dto }: { id: string; dto: UpdateDepartamentosPuestosDto }) =>
      departamentosPuestosApi.update(id, dto),
    onSuccess: (data) => {
      queryClient.invalidateQueries({ queryKey: [QUERY_KEY] });
      queryClient.setQueryData([QUERY_KEY, data.id], data);
      message.success('DepartamentosPuestos actualizado exitosamente');
    },
    onError: (error: any) => {
      const errorMsg = error.response?.data?.message || 'Error al actualizar departamentospuestos';
      message.error(errorMsg);
    },
  });
};

// Mutation: eliminar
export const useDeleteDepartamentosPuestos = () => {
  const queryClient = useQueryClient();

  return useMutation({
    mutationFn: (id: string) => departamentosPuestosApi.delete(id),
    onSuccess: () => {
      queryClient.invalidateQueries({ queryKey: [QUERY_KEY] });
      message.success('DepartamentosPuestos eliminado exitosamente');
    },
    onError: (error: any) => {
      const errorMsg = error.response?.data?.message || 'Error al eliminar departamentospuestos';
      message.error(errorMsg);
    },
  });
};

Components UI

Tabla de Listado

// src/widgets/DepartamentosPuestosTable/ui/DepartamentosPuestosTable.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 { useDepartamentosPuestoss, useDeleteDepartamentosPuestos } from '@entities/departamentosPuestos';
import { useDepartamentosPuestosStore } from '@entities/departamentosPuestos';
import type { DepartamentosPuestos, DepartamentosPuestosFilters } from '@entities/departamentosPuestos';

const { Search } = Input;

export const DepartamentosPuestosTable: React.FC = () => {
  const [filters, setFilters] = useState<DepartamentosPuestosFilters>({
    page: 1,
    limit: 20,
    sortBy: 'createdAt',
    sortOrder: 'desc',
  });

  const { data, isLoading } = useDepartamentosPuestoss(filters);
  const deleteMutation = useDeleteDepartamentosPuestos();
  const { openModal } = useDepartamentosPuestosStore();

  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<DepartamentosPuestos> = [
    {
      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 DepartamentosPuestos
        </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/createDepartamentosPuestos/ui/CreateDepartamentosPuestosForm.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 {
  createDepartamentosPuestosSchema,
  updateDepartamentosPuestosSchema,
  type CreateDepartamentosPuestosFormData
} from '@entities/departamentosPuestos';
import { useCreateDepartamentosPuestos, useUpdateDepartamentosPuestos } from '@entities/departamentosPuestos';
import type { DepartamentosPuestos } from '@entities/departamentosPuestos';

interface DepartamentosPuestosFormProps {
  mode: 'create' | 'edit';
  initialData?: DepartamentosPuestos;
  onSuccess?: () => void;
}

export const DepartamentosPuestosForm: React.FC<DepartamentosPuestosFormProps> = ({
  mode,
  initialData,
  onSuccess,
}) => {
  const isEditMode = mode === 'edit';
  const schema = isEditMode ? updateDepartamentosPuestosSchema : createDepartamentosPuestosSchema;

  const { control, handleSubmit, formState: { errors }, reset } = useForm<CreateDepartamentosPuestosFormData>({
    resolver: zodResolver(schema),
    defaultValues: initialData || {
      name: '',
      code: '',
    },
  });

  const createMutation = useCreateDepartamentosPuestos();
  const updateMutation = useUpdateDepartamentosPuestos();

  const mutation = isEditMode ? updateMutation : createMutation;

  useEffect(() => {
    if (initialData) {
      reset(initialData);
    }
  }, [initialData, reset]);

  const onSubmit = (data: CreateDepartamentosPuestosFormData) => {
    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/createDepartamentosPuestos/ui/CreateDepartamentosPuestosModal.tsx
import React from 'react';
import { Modal } from 'antd';
import { useDepartamentosPuestosStore } from '@entities/departamentosPuestos';
import { DepartamentosPuestosForm } from './CreateDepartamentosPuestosForm';

export const DepartamentosPuestosModal: React.FC = () => {
  const { isModalOpen, modalMode, selectedDepartamentosPuestos, closeModal } = useDepartamentosPuestosStore();

  const title = {
    create: 'Crear DepartamentosPuestos',
    edit: 'Editar DepartamentosPuestos',
    view: 'Ver DepartamentosPuestos',
  }[modalMode || 'create'];

  return (
    <Modal
      title={title}
      open={isModalOpen}
      onCancel={closeModal}
      footer={null}
      width={600}
      destroyOnClose
    >
      {modalMode !== 'view' ? (
        <DepartamentosPuestosForm
          mode={modalMode === 'edit' ? 'edit' : 'create'}
          initialData={selectedDepartamentosPuestos || undefined}
          onSuccess={closeModal}
        />
      ) : (
        <div>
          <p><strong>Nombre:</strong> {selectedDepartamentosPuestos?.name}</p>
          <p><strong>Código:</strong> {selectedDepartamentosPuestos?.code || 'N/A'}</p>
          <p><strong>Creado:</strong> {new Date(selectedDepartamentosPuestos?.createdAt || '').toLocaleString('es-ES')}</p>
        </div>
      )}
    </Modal>
  );
};

Página Principal

// src/pages/DepartamentosPuestosPage/DepartamentosPuestosPage.tsx
import React from 'react';
import { Card } from 'antd';
import { DepartamentosPuestosTable } from '@widgets/DepartamentosPuestosTable';
import { DepartamentosPuestosModal } from '@features/createDepartamentosPuestos';

export const DepartamentosPuestosPage: React.FC = () => {
  return (
    <div style={{ padding: 24 }}>
      <Card title="Departamentos y Puestos">
        <DepartamentosPuestosTable />
      </Card>
      <DepartamentosPuestosModal />
    </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 validateUniqueDepartamentosPuestosCode = async (code: string): Promise<boolean> => {
  try {
    const { data } = await departamentosPuestosApi.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 validateUniqueDepartamentosPuestosCode(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
  • 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: Progress para 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-010.departamentosPuestos.create') && (
  <Button type="primary" onClick={handleCreate}>
    Crear DepartamentosPuestos
  </Button>
)}

{can('mgn-010.departamentosPuestos.delete') && (
  <Button danger onClick={handleDelete}>
    Eliminar
  </Button>
)}

Testing

Component Tests (Vitest + React Testing Library)

// src/widgets/DepartamentosPuestosTable/ui/DepartamentosPuestosTable.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 { DepartamentosPuestosTable } from './DepartamentosPuestosTable';
import { departamentosPuestosApi } from '@entities/departamentosPuestos';

// Mock API
vi.mock('@entities/departamentosPuestos', () => ({
  departamentosPuestosApi: {
    getAll: vi.fn(),
    delete: vi.fn(),
  },
}));

describe('DepartamentosPuestosTable', () => {
  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 DepartamentosPuestos 1', code: 'TEST1', createdAt: '2025-11-24' },
        { id: '2', name: 'Test DepartamentosPuestos 2', code: 'TEST2', createdAt: '2025-11-24' },
      ],
      meta: { page: 1, limit: 20, total: 2, totalPages: 1 },
    };

    vi.mocked(departamentosPuestosApi.getAll).mockResolvedValue(mockData);

    render(<DepartamentosPuestosTable />, { wrapper });

    await waitFor(() => {
      expect(screen.getByText('Test DepartamentosPuestos 1')).toBeInTheDocument();
      expect(screen.getByText('Test DepartamentosPuestos 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(departamentosPuestosApi.getAll).mockResolvedValue(mockData);
    vi.mocked(departamentosPuestosApi.delete).mockResolvedValue();

    render(<DepartamentosPuestosTable />, { 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(departamentosPuestosApi.delete).toHaveBeenCalledWith('1');
    });
  });

  it('should filter data on search', async () => {
    const user = userEvent.setup();

    vi.mocked(departamentosPuestosApi.getAll).mockResolvedValue({
      data: [],
      meta: { page: 1, limit: 20, total: 0, totalPages: 0 },
    });

    render(<DepartamentosPuestosTable />, { wrapper });

    const searchInput = screen.getByPlaceholderText('Buscar por nombre o código');
    await user.type(searchInput, 'test');
    await user.keyboard('{Enter}');

    await waitFor(() => {
      expect(departamentosPuestosApi.getAll).toHaveBeenCalledWith(
        expect.objectContaining({ search: 'test' })
      );
    });
  });
});

E2E Tests (Playwright)

// e2e/departamentosPuestos.spec.ts
import { test, expect } from '@playwright/test';

test.describe('DepartamentosPuestos 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-010/departamentosPuestos');
  });

  test('should create new departamentosPuestos', async ({ page }) => {
    await page.goto('/mgn-010/departamentosPuestos');

    // Click create button
    await page.click('text=Crear DepartamentosPuestos');

    // Fill form
    await page.fill('[name="name"]', 'E2E Test DepartamentosPuestos');
    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 DepartamentosPuestos');
  });

  test('should edit existing departamentosPuestos', async ({ page }) => {
    await page.goto('/mgn-010/departamentosPuestos');

    // 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 departamentosPuestos with confirmation', async ({ page }) => {
    await page.goto('/mgn-010/departamentosPuestos');

    // 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-010/departamentosPuestos');

    // Click create button
    await page.click('text=Crear DepartamentosPuestos');

    // 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 departamentosPuestoss', async ({ page }) => {
    await page.goto('/mgn-010/departamentosPuestos');

    // 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 DepartamentosPuestosPage = React.lazy(() => import('@pages/DepartamentosPuestosPage'));

// 2. Memo para componentes pesados
export const DepartamentosPuestosCard = React.memo<DepartamentosPuestosCardProps>(({ 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ón
  • SharedModule - Componentes y utilities compartidos

RF Bloqueantes

  • RF-MGN-001-001 (Autenticación de Usuarios)
  • RF-MGN-010-002 Backend completado

Notas de Implementación

  • Crear estructura FSD: entities/departamentosPuestos, features/createDepartamentosPuestos, widgets/DepartamentosPuestosTable
  • Definir types e interfaces en entities/departamentosPuestos/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: 1 SP
  • Testing (Unit + e2e): 0 SP
  • Code Review + QA: 0 SP
  • Total: 3 SP

Documento generado: 2025-11-24 Versión: 1.0 Estado: Diseñado Próximo paso: Implementación