# ET-COMP-001: Implementación Frontend - Compras e Inventarios **Épica:** MAI-004 - Compras e Inventarios **Versión:** 1.0 **Fecha:** 2025-12-06 **Tipo:** Especificación Técnica Frontend --- ## 1. Resumen Ejecutivo Esta especificación define la arquitectura e implementación del frontend web para el módulo de Compras e Inventarios (MAI-004). El sistema está construido con React 18, Vite, Zustand y TanStack Table, proporcionando una interfaz moderna y eficiente para gestionar requisiciones, órdenes de compra, almacenes y movimientos de inventario. **Características principales:** - Gestión completa de requisiciones con flujo de aprobación - Órdenes de compra con generación de PDF - Control de almacenes e inventarios en tiempo real - Movimientos de inventario (entradas, salidas, transferencias) - Integración con app móvil MOB-002 (endpoints compartidos) - Dashboard con métricas y alertas - Exportación de reportes --- ## 2. Stack Tecnológico ### Core Framework ```yaml framework: React 18.2+ build_tool: Vite 5.x language: TypeScript 5.3+ package_manager: pnpm 8.x ``` ### State Management ```yaml global_state: Zustand 4.x server_state: TanStack Query v5 (React Query) forms: React Hook Form 7.x validation: Zod 3.x ``` ### UI & Components ```yaml styling: Tailwind CSS 3.x components: shadcn/ui icons: Lucide React tables: TanStack Table v8 charts: Recharts 2.x dates: date-fns 3.x pdf_generation: jsPDF + jspdf-autotable excel_export: xlsx notifications: sonner (toast notifications) ``` ### API & Data Fetching ```yaml http_client: Axios 1.x api_management: TanStack Query v5 websockets: Socket.io-client (para notificaciones en tiempo real) ``` ### Developer Tools ```yaml linting: ESLint + Prettier testing: Vitest + Testing Library types: TypeScript strict mode hot_reload: Vite HMR ``` --- ## 3. Estructura del Proyecto ``` src/ ├── features/ │ ├── requisitions/ # Feature: Requisiciones │ │ ├── api/ │ │ │ ├── requisitions.api.ts │ │ │ └── types.ts │ │ ├── components/ │ │ │ ├── RequisitionForm.tsx │ │ │ ├── RequisitionList.tsx │ │ │ ├── RequisitionDetail.tsx │ │ │ ├── RequisitionItemsTable.tsx │ │ │ ├── RequisitionApprovalPanel.tsx │ │ │ └── RequisitionWorkflowTimeline.tsx │ │ ├── hooks/ │ │ │ ├── useRequisitions.ts │ │ │ ├── useRequisitionMutations.ts │ │ │ └── useRequisitionValidation.ts │ │ ├── store/ │ │ │ └── requisitionsStore.ts │ │ └── routes.tsx │ │ │ ├── purchase-orders/ # Feature: Órdenes de Compra │ │ ├── api/ │ │ │ ├── purchase-orders.api.ts │ │ │ └── types.ts │ │ ├── components/ │ │ │ ├── PurchaseOrderForm.tsx │ │ │ ├── PurchaseOrderList.tsx │ │ │ ├── PurchaseOrderDetail.tsx │ │ │ ├── PurchaseOrderItemsTable.tsx │ │ │ ├── PurchaseOrderPDFPreview.tsx │ │ │ ├── PurchaseOrderReceiptModal.tsx │ │ │ └── PurchaseOrderStatusBadge.tsx │ │ ├── hooks/ │ │ │ ├── usePurchaseOrders.ts │ │ │ ├── usePurchaseOrderMutations.ts │ │ │ └── usePurchaseOrderPDF.ts │ │ ├── store/ │ │ │ └── purchaseOrdersStore.ts │ │ ├── utils/ │ │ │ └── pdfGenerator.ts │ │ └── routes.tsx │ │ │ ├── warehouses/ # Feature: Almacenes │ │ ├── api/ │ │ │ ├── warehouses.api.ts │ │ │ └── types.ts │ │ ├── components/ │ │ │ ├── WarehouseSelector.tsx │ │ │ ├── WarehouseList.tsx │ │ │ ├── WarehouseForm.tsx │ │ │ ├── WarehouseDetail.tsx │ │ │ ├── LocationsTreeView.tsx │ │ │ ├── StockLevelCard.tsx │ │ │ └── LowStockAlert.tsx │ │ ├── hooks/ │ │ │ ├── useWarehouses.ts │ │ │ ├── useWarehouseMutations.ts │ │ │ └── useStockLevels.ts │ │ ├── store/ │ │ │ └── warehousesStore.ts │ │ └── routes.tsx │ │ │ └── inventory/ # Feature: Movimientos de Inventario │ ├── api/ │ │ ├── inventory.api.ts │ │ └── types.ts │ ├── components/ │ │ ├── InventoryMovementForm.tsx │ │ ├── InventoryMovementList.tsx │ │ ├── InventoryEntryForm.tsx │ │ ├── InventoryExitForm.tsx │ │ ├── InventoryTransferForm.tsx │ │ ├── InventoryAdjustmentForm.tsx │ │ ├── KardexView.tsx │ │ └── InventoryDashboard.tsx │ ├── hooks/ │ │ ├── useInventoryMovements.ts │ │ ├── useInventoryMutations.ts │ │ └── useKardex.ts │ ├── store/ │ │ └── inventoryStore.ts │ └── routes.tsx │ ├── shared/ │ ├── components/ # Componentes compartidos │ │ ├── ui/ # shadcn/ui components │ │ ├── DataTable.tsx │ │ ├── SearchInput.tsx │ │ ├── DateRangePicker.tsx │ │ └── FileUpload.tsx │ ├── hooks/ │ │ ├── useAuth.ts │ │ ├── usePermissions.ts │ │ └── useNotifications.ts │ ├── lib/ │ │ ├── axios.ts │ │ ├── queryClient.ts │ │ └── utils.ts │ └── types/ │ └── common.ts │ ├── config/ │ ├── env.ts │ └── constants.ts │ └── App.tsx ``` --- ## 4. Feature Modules ### 4.1 Requisitions Module **Responsabilidad:** Gestión de requisiciones de materiales con flujo de aprobación. #### Store (Zustand) ```typescript // src/features/requisitions/store/requisitionsStore.ts import { create } from 'zustand'; import { devtools } from 'zustand/middleware'; interface RequisitionItem { id: string; materialId: string; materialCode: string; description: string; quantity: number; unit: string; budgetedPrice: number; budgetItemId: string; notes?: string; } interface ApprovalStep { level: number; approverRole: string; approverId?: string; approverName?: string; status: 'pending' | 'approved' | 'rejected'; comments?: string; approvedAt?: Date; } interface Requisition { id: string; code: string; constructoraId: string; projectId: string; projectName: string; requestedBy: string; requestedByName: string; requiredDate: Date; urgency: 'normal' | 'urgent'; items: RequisitionItem[]; justification?: string; estimatedTotal: number; status: 'draft' | 'pending' | 'approved' | 'rejected' | 'quoted' | 'ordered'; approvalFlow?: ApprovalStep[]; rejectedReason?: string; createdAt: Date; updatedAt: Date; } interface RequisitionsState { // State requisitions: Requisition[]; selectedRequisition: Requisition | null; isLoading: boolean; filters: { projectId?: string; status?: string; dateFrom?: Date; dateTo?: Date; searchTerm?: string; }; // Actions setRequisitions: (requisitions: Requisition[]) => void; setSelectedRequisition: (requisition: Requisition | null) => void; setLoading: (loading: boolean) => void; setFilters: (filters: Partial) => void; resetFilters: () => void; // Computed getFilteredRequisitions: () => Requisition[]; getPendingApprovals: (userId: string) => Requisition[]; } export const useRequisitionsStore = create()( devtools( (set, get) => ({ // Initial state requisitions: [], selectedRequisition: null, isLoading: false, filters: {}, // Actions setRequisitions: (requisitions) => set({ requisitions }), setSelectedRequisition: (requisition) => set({ selectedRequisition: requisition }), setLoading: (loading) => set({ isLoading: loading }), setFilters: (filters) => set((state) => ({ filters: { ...state.filters, ...filters } })), resetFilters: () => set({ filters: {} }), // Computed getFilteredRequisitions: () => { const { requisitions, filters } = get(); return requisitions.filter((req) => { if (filters.projectId && req.projectId !== filters.projectId) return false; if (filters.status && req.status !== filters.status) return false; if (filters.dateFrom && new Date(req.createdAt) < filters.dateFrom) return false; if (filters.dateTo && new Date(req.createdAt) > filters.dateTo) return false; if (filters.searchTerm) { const term = filters.searchTerm.toLowerCase(); return ( req.code.toLowerCase().includes(term) || req.projectName.toLowerCase().includes(term) || req.justification?.toLowerCase().includes(term) ); } return true; }); }, getPendingApprovals: (userId) => { const { requisitions } = get(); return requisitions.filter((req) => { if (req.status !== 'pending') return false; const nextStep = req.approvalFlow?.find((step) => step.status === 'pending'); return nextStep?.approverId === userId; }); }, }), { name: 'requisitions-store' } ) ); ``` #### API Layer ```typescript // src/features/requisitions/api/requisitions.api.ts import { apiClient } from '@/shared/lib/axios'; import type { Requisition, CreateRequisitionDto, UpdateRequisitionDto } from './types'; export const requisitionsApi = { // GET /api/requisitions getAll: async (params?: { projectId?: string; status?: string; dateFrom?: string; dateTo?: string; }): Promise => { const { data } = await apiClient.get('/requisitions', { params }); return data; }, // GET /api/requisitions/:id getById: async (id: string): Promise => { const { data } = await apiClient.get(`/requisitions/${id}`); return data; }, // POST /api/requisitions create: async (dto: CreateRequisitionDto): Promise => { const { data } = await apiClient.post('/requisitions', dto); return data; }, // PUT /api/requisitions/:id update: async (id: string, dto: UpdateRequisitionDto): Promise => { const { data } = await apiClient.put(`/requisitions/${id}`, dto); return data; }, // POST /api/requisitions/:id/submit submitForApproval: async (id: string): Promise => { const { data } = await apiClient.post(`/requisitions/${id}/submit`); return data; }, // POST /api/requisitions/:id/approve approve: async (id: string, level: number, comments?: string): Promise => { const { data } = await apiClient.post(`/requisitions/${id}/approve`, { level, comments }); return data; }, // POST /api/requisitions/:id/reject reject: async (id: string, reason: string): Promise => { const { data } = await apiClient.post(`/requisitions/${id}/reject`, { reason }); return data; }, // POST /api/requisitions/validate-budget validateBudget: async (item: { projectId: string; budgetItemId: string; quantity: number; budgetedPrice: number; }): Promise<{ valid: boolean; available: number; requested: number; }> => { const { data } = await apiClient.post('/requisitions/validate-budget', item); return data; }, // DELETE /api/requisitions/:id delete: async (id: string): Promise => { await apiClient.delete(`/requisitions/${id}`); }, }; ``` #### Custom Hooks ```typescript // src/features/requisitions/hooks/useRequisitions.ts import { useQuery } from '@tanstack/react-query'; import { requisitionsApi } from '../api/requisitions.api'; import { useRequisitionsStore } from '../store/requisitionsStore'; export const useRequisitions = (params?: { projectId?: string; status?: string; }) => { const setRequisitions = useRequisitionsStore((state) => state.setRequisitions); const setLoading = useRequisitionsStore((state) => state.setLoading); return useQuery({ queryKey: ['requisitions', params], queryFn: () => requisitionsApi.getAll(params), onSuccess: (data) => { setRequisitions(data); setLoading(false); }, onError: () => { setLoading(false); }, }); }; // src/features/requisitions/hooks/useRequisitionMutations.ts import { useMutation, useQueryClient } from '@tanstack/react-query'; import { toast } from 'sonner'; import { requisitionsApi } from '../api/requisitions.api'; export const useRequisitionMutations = () => { const queryClient = useQueryClient(); const createMutation = useMutation({ mutationFn: requisitionsApi.create, onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['requisitions'] }); toast.success('Requisición creada exitosamente'); }, onError: (error: any) => { toast.error(error.response?.data?.message || 'Error al crear requisición'); }, }); const submitMutation = useMutation({ mutationFn: requisitionsApi.submitForApproval, onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['requisitions'] }); toast.success('Requisición enviada a aprobación'); }, onError: (error: any) => { toast.error(error.response?.data?.message || 'Error al enviar requisición'); }, }); const approveMutation = useMutation({ mutationFn: ({ id, level, comments }: { id: string; level: number; comments?: string }) => requisitionsApi.approve(id, level, comments), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['requisitions'] }); toast.success('Requisición aprobada'); }, onError: (error: any) => { toast.error(error.response?.data?.message || 'Error al aprobar requisición'); }, }); const rejectMutation = useMutation({ mutationFn: ({ id, reason }: { id: string; reason: string }) => requisitionsApi.reject(id, reason), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['requisitions'] }); toast.success('Requisición rechazada'); }, onError: (error: any) => { toast.error(error.response?.data?.message || 'Error al rechazar requisición'); }, }); return { create: createMutation, submit: submitMutation, approve: approveMutation, reject: rejectMutation, }; }; ``` #### Components ```typescript // src/features/requisitions/components/RequisitionForm.tsx import { useForm } from 'react-hook-form'; import { zodResolver } from '@hookform/resolvers/zod'; import { z } from 'zod'; import { Button } from '@/shared/components/ui/button'; import { Form, FormControl, FormField, FormItem, FormLabel } from '@/shared/components/ui/form'; import { Input } from '@/shared/components/ui/input'; import { Select } from '@/shared/components/ui/select'; import { Textarea } from '@/shared/components/ui/textarea'; import { RequisitionItemsTable } from './RequisitionItemsTable'; import { useRequisitionMutations } from '../hooks/useRequisitionMutations'; const requisitionSchema = z.object({ projectId: z.string().uuid('Debe seleccionar un proyecto'), requiredDate: z.string().min(1, 'Fecha requerida es obligatoria'), urgency: z.enum(['normal', 'urgent']), items: z.array(z.object({ materialId: z.string().uuid(), description: z.string().min(1), quantity: z.number().positive(), unit: z.string(), budgetedPrice: z.number().positive(), budgetItemId: z.string().uuid(), notes: z.string().optional(), })).min(1, 'Debe agregar al menos un item'), justification: z.string().optional(), }); type RequisitionFormData = z.infer; interface RequisitionFormProps { onSuccess?: () => void; onCancel?: () => void; } export const RequisitionForm: React.FC = ({ onSuccess, onCancel }) => { const { create, submit } = useRequisitionMutations(); const form = useForm({ resolver: zodResolver(requisitionSchema), defaultValues: { urgency: 'normal', items: [], }, }); const handleSaveDraft = async (data: RequisitionFormData) => { await create.mutateAsync(data); onSuccess?.(); }; const handleSubmit = async (data: RequisitionFormData) => { const requisition = await create.mutateAsync(data); await submit.mutateAsync(requisition.id); onSuccess?.(); }; return (
( Proyecto )} /> ( Fecha Requerida )} /> ( Urgencia )} />
( Materiales )} /> ( Justificación