erp-core/docs/04-modelado/especificaciones-tecnicas/frontend/mgn-006/ET-FRONTEND-MGN-006-001-solicitudes-de-cotización-rfq.md

34 KiB

ET-FRONTEND-MGN-006-001: Solicitudes de Cotización (RFQ)

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

Resumen Técnico

Implementación frontend para solicitudes de cotización (rfq). 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
│   └── SolicitudesCotizaciónrfqPage/
│       ├── index.tsx
│       └── SolicitudesCotizaciónrfqPage.tsx
├── widgets/                      # Complex UI blocks
│   └── SolicitudesCotizaciónrfqTable/
│       ├── ui/
│       │   └── SolicitudesCotizaciónrfqTable.tsx
│       └── index.ts
├── features/                     # User interactions
│   ├── createSolicitudesCotizaciónrfq/
│   │   ├── ui/
│   │   │   └── CreateSolicitudesCotizaciónrfqForm.tsx
│   │   ├── model/
│   │   │   └── useSolicitudesCotizaciónrfqActions.ts
│   │   └── index.ts
│   ├── updateSolicitudesCotizaciónrfq/
│   └── deleteSolicitudesCotizaciónrfq/
├── entities/                     # Business entities
│   └── solicitudesCotizaciónrfq/
│       ├── model/
│       │   ├── types.ts
│       │   ├── schemas.ts
│       │   └── solicitudesCotizaciónrfq.store.ts
│       ├── api/
│       │   ├── solicitudesCotizaciónrfq.api.ts
│       │   └── solicitudesCotizaciónrfq.queries.ts
│       ├── ui/
│       │   └── SolicitudesCotizaciónrfqCard.tsx
│       └── index.ts
└── shared/                       # Shared code
    ├── ui/                       # UI kit
    │   ├── Button/
    │   ├── Modal/
    │   └── Table/
    ├── api/
    │   └── client.ts
    └── lib/
        └── utils.ts

Rutas

// src/app/routes/mgn-006.routes.tsx
export const SolicitudesCotizaciónrfqRoutes = {
  list: '/mgn-006/solicitudesCotizaciónrfq',
  create: '/mgn-006/solicitudesCotizaciónrfq/create',
  edit: '/mgn-006/solicitudesCotizaciónrfq/:id/edit',
  view: '/mgn-006/solicitudesCotizaciónrfq/:id',
};

// Integración en Router
<Route path="/mgn-006">
  <Route path="solicitudesCotizaciónrfq" element={<SolicitudesCotizaciónrfqPage />} />
  <Route path="solicitudesCotizaciónrfq/create" element={<CreateSolicitudesCotizaciónrfqPage />} />
  <Route path="solicitudesCotizaciónrfq/:id/edit" element={<EditSolicitudesCotizaciónrfqPage />} />
  <Route path="solicitudesCotizaciónrfq/:id" element={<ViewSolicitudesCotizaciónrfqPage />} />
</Route>

Types / Interfaces

// src/entities/solicitudesCotizaciónrfq/model/types.ts

export interface SolicitudesCotizaciónrfq {
  id: string;
  tenantId: string;
  name: string;
  code?: string;
  createdAt: string;
  updatedAt?: string;
  deletedAt?: string;
}

export interface CreateSolicitudesCotizaciónrfqDto {
  name: string;
  code?: string;
}

export type UpdateSolicitudesCotizaciónrfqDto = Partial<CreateSolicitudesCotizaciónrfqDto>;

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

export interface SolicitudesCotizaciónrfqListResponse {
  data: SolicitudesCotizaciónrfq[];
  meta: {
    page: number;
    limit: number;
    total: number;
    totalPages: number;
  };
}

Schemas de Validación (Zod)

// src/entities/solicitudesCotizaciónrfq/model/schemas.ts
import { z } from 'zod';

export const createSolicitudesCotizaciónrfqSchema = 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 updateSolicitudesCotizaciónrfqSchema = createSolicitudesCotizaciónrfqSchema.partial();

export type CreateSolicitudesCotizaciónrfqFormData = z.infer<typeof createSolicitudesCotizaciónrfqSchema>;
export type UpdateSolicitudesCotizaciónrfqFormData = z.infer<typeof updateSolicitudesCotizaciónrfqSchema>;

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

API Client

// src/entities/solicitudesCotizaciónrfq/api/solicitudesCotizaciónrfq.api.ts
import { apiClient } from '@shared/api/client';
import type {
  SolicitudesCotizaciónrfq,
  CreateSolicitudesCotizaciónrfqDto,
  UpdateSolicitudesCotizaciónrfqDto,
  SolicitudesCotizaciónrfqFilters,
  SolicitudesCotizaciónrfqListResponse
} from '../model/types';

const BASE_URL = '/api/v1/purchase/rfq';

export const solicitudesCotizaciónrfqApi = {
  getAll: async (filters?: SolicitudesCotizaciónrfqFilters): Promise<SolicitudesCotizaciónrfqListResponse> => {
    const { data } = await apiClient.get<SolicitudesCotizaciónrfqListResponse>(BASE_URL, {
      params: filters,
    });
    return data;
  },

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

  create: async (dto: CreateSolicitudesCotizaciónrfqDto): Promise<SolicitudesCotizaciónrfq> => {
    const { data } = await apiClient.post<{ data: SolicitudesCotizaciónrfq }>(BASE_URL, dto);
    return data.data;
  },

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

interface SolicitudesCotizaciónrfqStore {
  selectedSolicitudesCotizaciónrfq: SolicitudesCotizaciónrfq | null;
  isModalOpen: boolean;
  modalMode: 'create' | 'edit' | 'view' | null;

  setSelectedSolicitudesCotizaciónrfq: (entity: SolicitudesCotizaciónrfq | null) => void;
  openModal: (mode: 'create' | 'edit' | 'view', entity?: SolicitudesCotizaciónrfq) => void;
  closeModal: () => void;
}

export const useSolicitudesCotizaciónrfqStore = create<SolicitudesCotizaciónrfqStore>((set) => ({
  selectedSolicitudesCotizaciónrfq: null,
  isModalOpen: false,
  modalMode: null,

  setSelectedSolicitudesCotizaciónrfq: (entity) => set({ selectedSolicitudesCotizaciónrfq: entity }),

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

  closeModal: () => set({
    isModalOpen: false,
    modalMode: null,
    selectedSolicitudesCotizaciónrfq: null,
  }),
}));

React Query Hooks (servidor state)

// src/entities/solicitudesCotizaciónrfq/api/solicitudesCotizaciónrfq.queries.ts
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { message } from 'antd';
import { solicitudesCotizaciónrfqApi } from './solicitudesCotizaciónrfq.api';
import type { CreateSolicitudesCotizaciónrfqDto, UpdateSolicitudesCotizaciónrfqDto, SolicitudesCotizaciónrfqFilters } from '../model/types';

const QUERY_KEY = 'solicitudesCotizaciónrfq';

// Query: obtener lista
export const useSolicitudesCotizaciónrfqs = (filters?: SolicitudesCotizaciónrfqFilters) => {
  return useQuery({
    queryKey: [QUERY_KEY, filters],
    queryFn: () => solicitudesCotizaciónrfqApi.getAll(filters),
    staleTime: 5 * 60 * 1000, // 5 minutos
  });
};

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

// Mutation: crear
export const useCreateSolicitudesCotizaciónrfq = () => {
  const queryClient = useQueryClient();

  return useMutation({
    mutationFn: (dto: CreateSolicitudesCotizaciónrfqDto) => solicitudesCotizaciónrfqApi.create(dto),
    onSuccess: () => {
      queryClient.invalidateQueries({ queryKey: [QUERY_KEY] });
      message.success('SolicitudesCotizaciónrfq creado exitosamente');
    },
    onError: (error: any) => {
      const errorMsg = error.response?.data?.message || 'Error al crear solicitudescotizaciónrfq';
      message.error(errorMsg);
    },
  });
};

// Mutation: actualizar
export const useUpdateSolicitudesCotizaciónrfq = () => {
  const queryClient = useQueryClient();

  return useMutation({
    mutationFn: ({ id, dto }: { id: string; dto: UpdateSolicitudesCotizaciónrfqDto }) =>
      solicitudesCotizaciónrfqApi.update(id, dto),
    onSuccess: (data) => {
      queryClient.invalidateQueries({ queryKey: [QUERY_KEY] });
      queryClient.setQueryData([QUERY_KEY, data.id], data);
      message.success('SolicitudesCotizaciónrfq actualizado exitosamente');
    },
    onError: (error: any) => {
      const errorMsg = error.response?.data?.message || 'Error al actualizar solicitudescotizaciónrfq';
      message.error(errorMsg);
    },
  });
};

// Mutation: eliminar
export const useDeleteSolicitudesCotizaciónrfq = () => {
  const queryClient = useQueryClient();

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

Components UI

Tabla de Listado

// src/widgets/SolicitudesCotizaciónrfqTable/ui/SolicitudesCotizaciónrfqTable.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 { useSolicitudesCotizaciónrfqs, useDeleteSolicitudesCotizaciónrfq } from '@entities/solicitudesCotizaciónrfq';
import { useSolicitudesCotizaciónrfqStore } from '@entities/solicitudesCotizaciónrfq';
import type { SolicitudesCotizaciónrfq, SolicitudesCotizaciónrfqFilters } from '@entities/solicitudesCotizaciónrfq';

const { Search } = Input;

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

  const { data, isLoading } = useSolicitudesCotizaciónrfqs(filters);
  const deleteMutation = useDeleteSolicitudesCotizaciónrfq();
  const { openModal } = useSolicitudesCotizaciónrfqStore();

  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<SolicitudesCotizaciónrfq> = [
    {
      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 SolicitudesCotizaciónrfq
        </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/createSolicitudesCotizaciónrfq/ui/CreateSolicitudesCotizaciónrfqForm.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 {
  createSolicitudesCotizaciónrfqSchema,
  updateSolicitudesCotizaciónrfqSchema,
  type CreateSolicitudesCotizaciónrfqFormData
} from '@entities/solicitudesCotizaciónrfq';
import { useCreateSolicitudesCotizaciónrfq, useUpdateSolicitudesCotizaciónrfq } from '@entities/solicitudesCotizaciónrfq';
import type { SolicitudesCotizaciónrfq } from '@entities/solicitudesCotizaciónrfq';

interface SolicitudesCotizaciónrfqFormProps {
  mode: 'create' | 'edit';
  initialData?: SolicitudesCotizaciónrfq;
  onSuccess?: () => void;
}

export const SolicitudesCotizaciónrfqForm: React.FC<SolicitudesCotizaciónrfqFormProps> = ({
  mode,
  initialData,
  onSuccess,
}) => {
  const isEditMode = mode === 'edit';
  const schema = isEditMode ? updateSolicitudesCotizaciónrfqSchema : createSolicitudesCotizaciónrfqSchema;

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

  const createMutation = useCreateSolicitudesCotizaciónrfq();
  const updateMutation = useUpdateSolicitudesCotizaciónrfq();

  const mutation = isEditMode ? updateMutation : createMutation;

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

  const onSubmit = (data: CreateSolicitudesCotizaciónrfqFormData) => {
    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/createSolicitudesCotizaciónrfq/ui/CreateSolicitudesCotizaciónrfqModal.tsx
import React from 'react';
import { Modal } from 'antd';
import { useSolicitudesCotizaciónrfqStore } from '@entities/solicitudesCotizaciónrfq';
import { SolicitudesCotizaciónrfqForm } from './CreateSolicitudesCotizaciónrfqForm';

export const SolicitudesCotizaciónrfqModal: React.FC = () => {
  const { isModalOpen, modalMode, selectedSolicitudesCotizaciónrfq, closeModal } = useSolicitudesCotizaciónrfqStore();

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

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

Página Principal

// src/pages/SolicitudesCotizaciónrfqPage/SolicitudesCotizaciónrfqPage.tsx
import React from 'react';
import { Card } from 'antd';
import { SolicitudesCotizaciónrfqTable } from '@widgets/SolicitudesCotizaciónrfqTable';
import { SolicitudesCotizaciónrfqModal } from '@features/createSolicitudesCotizaciónrfq';

export const SolicitudesCotizaciónrfqPage: React.FC = () => {
  return (
    <div style={{ padding: 24 }}>
      <Card title="Solicitudes de Cotización (RFQ)">
        <SolicitudesCotizaciónrfqTable />
      </Card>
      <SolicitudesCotizaciónrfqModal />
    </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 validateUniqueSolicitudesCotizaciónrfqCode = async (code: string): Promise<boolean> => {
  try {
    const { data } = await solicitudesCotizaciónrfqApi.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 validateUniqueSolicitudesCotizaciónrfqCode(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-006.solicitudesCotizaciónrfq.create') && (
  <Button type="primary" onClick={handleCreate}>
    Crear SolicitudesCotizaciónrfq
  </Button>
)}

{can('mgn-006.solicitudesCotizaciónrfq.delete') && (
  <Button danger onClick={handleDelete}>
    Eliminar
  </Button>
)}

Testing

Component Tests (Vitest + React Testing Library)

// src/widgets/SolicitudesCotizaciónrfqTable/ui/SolicitudesCotizaciónrfqTable.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 { SolicitudesCotizaciónrfqTable } from './SolicitudesCotizaciónrfqTable';
import { solicitudesCotizaciónrfqApi } from '@entities/solicitudesCotizaciónrfq';

// Mock API
vi.mock('@entities/solicitudesCotizaciónrfq', () => ({
  solicitudesCotizaciónrfqApi: {
    getAll: vi.fn(),
    delete: vi.fn(),
  },
}));

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

    vi.mocked(solicitudesCotizaciónrfqApi.getAll).mockResolvedValue(mockData);

    render(<SolicitudesCotizaciónrfqTable />, { wrapper });

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

    render(<SolicitudesCotizaciónrfqTable />, { 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(solicitudesCotizaciónrfqApi.delete).toHaveBeenCalledWith('1');
    });
  });

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

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

    render(<SolicitudesCotizaciónrfqTable />, { wrapper });

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

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

E2E Tests (Playwright)

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

test.describe('SolicitudesCotizaciónrfq 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-006/solicitudesCotizaciónrfq');
  });

  test('should create new solicitudesCotizaciónrfq', async ({ page }) => {
    await page.goto('/mgn-006/solicitudesCotizaciónrfq');

    // Click create button
    await page.click('text=Crear SolicitudesCotizaciónrfq');

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

  test('should edit existing solicitudesCotizaciónrfq', async ({ page }) => {
    await page.goto('/mgn-006/solicitudesCotizaciónrfq');

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

    // 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-006/solicitudesCotizaciónrfq');

    // Click create button
    await page.click('text=Crear SolicitudesCotizaciónrfq');

    // 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 solicitudesCotizaciónrfqs', async ({ page }) => {
    await page.goto('/mgn-006/solicitudesCotizaciónrfq');

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

// 2. Memo para componentes pesados
export const SolicitudesCotizaciónrfqCard = React.memo<SolicitudesCotizaciónrfqCardProps>(({ 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-006-001 Backend completado

Notas de Implementación

  • Crear estructura FSD: entities/solicitudesCotizaciónrfq, features/createSolicitudesCotizaciónrfq, widgets/SolicitudesCotizaciónrfqTable
  • Definir types e interfaces en entities/solicitudesCotizaciónrfq/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