erp-construccion/docs/02-definicion-modulos/MAI-004-compras-inventarios/especificaciones/ET-COMP-001-frontend.md

44 KiB

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

framework: React 18.2+
build_tool: Vite 5.x
language: TypeScript 5.3+
package_manager: pnpm 8.x

State Management

global_state: Zustand 4.x
server_state: TanStack Query v5 (React Query)
forms: React Hook Form 7.x
validation: Zod 3.x

UI & Components

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

http_client: Axios 1.x
api_management: TanStack Query v5
websockets: Socket.io-client (para notificaciones en tiempo real)

Developer Tools

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)

// 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<RequisitionsState['filters']>) => void;
  resetFilters: () => void;

  // Computed
  getFilteredRequisitions: () => Requisition[];
  getPendingApprovals: (userId: string) => Requisition[];
}

export const useRequisitionsStore = create<RequisitionsState>()(
  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

// 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<Requisition[]> => {
    const { data } = await apiClient.get('/requisitions', { params });
    return data;
  },

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

  // POST /api/requisitions
  create: async (dto: CreateRequisitionDto): Promise<Requisition> => {
    const { data } = await apiClient.post('/requisitions', dto);
    return data;
  },

  // PUT /api/requisitions/:id
  update: async (id: string, dto: UpdateRequisitionDto): Promise<Requisition> => {
    const { data } = await apiClient.put(`/requisitions/${id}`, dto);
    return data;
  },

  // POST /api/requisitions/:id/submit
  submitForApproval: async (id: string): Promise<Requisition> => {
    const { data } = await apiClient.post(`/requisitions/${id}/submit`);
    return data;
  },

  // POST /api/requisitions/:id/approve
  approve: async (id: string, level: number, comments?: string): Promise<Requisition> => {
    const { data } = await apiClient.post(`/requisitions/${id}/approve`, { level, comments });
    return data;
  },

  // POST /api/requisitions/:id/reject
  reject: async (id: string, reason: string): Promise<Requisition> => {
    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<void> => {
    await apiClient.delete(`/requisitions/${id}`);
  },
};

Custom Hooks

// 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

// 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<typeof requisitionSchema>;

interface RequisitionFormProps {
  onSuccess?: () => void;
  onCancel?: () => void;
}

export const RequisitionForm: React.FC<RequisitionFormProps> = ({ onSuccess, onCancel }) => {
  const { create, submit } = useRequisitionMutations();

  const form = useForm<RequisitionFormData>({
    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 (
    <Form {...form}>
      <form className="space-y-6">
        <div className="grid grid-cols-2 gap-4">
          <FormField
            control={form.control}
            name="projectId"
            render={({ field }) => (
              <FormItem>
                <FormLabel>Proyecto</FormLabel>
                <FormControl>
                  <Select {...field}>
                    {/* Options */}
                  </Select>
                </FormControl>
              </FormItem>
            )}
          />

          <FormField
            control={form.control}
            name="requiredDate"
            render={({ field }) => (
              <FormItem>
                <FormLabel>Fecha Requerida</FormLabel>
                <FormControl>
                  <Input type="date" {...field} />
                </FormControl>
              </FormItem>
            )}
          />

          <FormField
            control={form.control}
            name="urgency"
            render={({ field }) => (
              <FormItem>
                <FormLabel>Urgencia</FormLabel>
                <FormControl>
                  <Select {...field}>
                    <option value="normal">Normal</option>
                    <option value="urgent">Urgente</option>
                  </Select>
                </FormControl>
              </FormItem>
            )}
          />
        </div>

        <FormField
          control={form.control}
          name="items"
          render={({ field }) => (
            <FormItem>
              <FormLabel>Materiales</FormLabel>
              <FormControl>
                <RequisitionItemsTable
                  items={field.value}
                  onChange={field.onChange}
                />
              </FormControl>
            </FormItem>
          )}
        />

        <FormField
          control={form.control}
          name="justification"
          render={({ field }) => (
            <FormItem>
              <FormLabel>Justificación</FormLabel>
              <FormControl>
                <Textarea {...field} rows={4} />
              </FormControl>
            </FormItem>
          )}
        />

        <div className="flex justify-end gap-3">
          <Button type="button" variant="outline" onClick={onCancel}>
            Cancelar
          </Button>
          <Button
            type="button"
            variant="secondary"
            onClick={form.handleSubmit(handleSaveDraft)}
            disabled={create.isLoading}
          >
            Guardar Borrador
          </Button>
          <Button
            type="button"
            onClick={form.handleSubmit(handleSubmit)}
            disabled={create.isLoading || submit.isLoading}
          >
            Enviar a Aprobación
          </Button>
        </div>
      </form>
    </Form>
  );
};

4.2 Purchase Orders Module

Responsabilidad: Gestión de órdenes de compra y recepciones.

Store (Zustand)

// src/features/purchase-orders/store/purchaseOrdersStore.ts
import { create } from 'zustand';
import { devtools } from 'zustand/middleware';

interface PurchaseOrderItem {
  id: string;
  materialId: string;
  description: string;
  quantity: number;
  unit: string;
  unitPrice: number;
  subtotal: number;
  budgetItemId?: string;
}

interface PurchaseOrder {
  id: string;
  code: string;
  constructoraId: string;
  supplierId: string;
  supplierName: string;
  projectId: string;
  projectName: string;
  requisitionId?: string;
  orderDate: Date;
  deliveryDate: Date;
  deliveryAddress: string;
  items: PurchaseOrderItem[];
  subtotal: number;
  tax: number;
  total: number;
  paymentTerms?: string;
  paymentTermsDays: number;
  status: 'pending' | 'approved' | 'sent' | 'partially_received' | 'received' | 'cancelled';
  approvedBy?: string;
  approvedAt?: Date;
  sentToSupplierAt?: Date;
  createdAt: Date;
  updatedAt: Date;
}

interface PurchaseOrdersState {
  purchaseOrders: PurchaseOrder[];
  selectedPurchaseOrder: PurchaseOrder | null;
  isLoading: boolean;
  filters: {
    projectId?: string;
    supplierId?: string;
    status?: string;
    dateFrom?: Date;
    dateTo?: Date;
  };

  setPurchaseOrders: (orders: PurchaseOrder[]) => void;
  setSelectedPurchaseOrder: (order: PurchaseOrder | null) => void;
  setLoading: (loading: boolean) => void;
  setFilters: (filters: Partial<PurchaseOrdersState['filters']>) => void;
  resetFilters: () => void;
  getFilteredPurchaseOrders: () => PurchaseOrder[];
}

export const usePurchaseOrdersStore = create<PurchaseOrdersState>()(
  devtools(
    (set, get) => ({
      purchaseOrders: [],
      selectedPurchaseOrder: null,
      isLoading: false,
      filters: {},

      setPurchaseOrders: (orders) => set({ purchaseOrders: orders }),
      setSelectedPurchaseOrder: (order) => set({ selectedPurchaseOrder: order }),
      setLoading: (loading) => set({ isLoading: loading }),
      setFilters: (filters) => set((state) => ({ filters: { ...state.filters, ...filters } })),
      resetFilters: () => set({ filters: {} }),

      getFilteredPurchaseOrders: () => {
        const { purchaseOrders, filters } = get();

        return purchaseOrders.filter((po) => {
          if (filters.projectId && po.projectId !== filters.projectId) return false;
          if (filters.supplierId && po.supplierId !== filters.supplierId) return false;
          if (filters.status && po.status !== filters.status) return false;
          if (filters.dateFrom && new Date(po.orderDate) < filters.dateFrom) return false;
          if (filters.dateTo && new Date(po.orderDate) > filters.dateTo) return false;
          return true;
        });
      },
    }),
    { name: 'purchase-orders-store' }
  )
);

Components

// src/features/purchase-orders/components/PurchaseOrderList.tsx
import { useState } from 'react';
import { useReactTable, getCoreRowModel, ColumnDef } from '@tanstack/react-table';
import { DataTable } from '@/shared/components/DataTable';
import { Badge } from '@/shared/components/ui/badge';
import { Button } from '@/shared/components/ui/button';
import { usePurchaseOrdersStore } from '../store/purchaseOrdersStore';
import { formatCurrency, formatDate } from '@/shared/lib/utils';
import { Eye, Download, Send } from 'lucide-react';

export const PurchaseOrderList: React.FC = () => {
  const purchaseOrders = usePurchaseOrdersStore((state) => state.getFilteredPurchaseOrders());

  const columns: ColumnDef<PurchaseOrder>[] = [
    {
      accessorKey: 'code',
      header: 'Folio',
      cell: ({ row }) => (
        <span className="font-mono font-medium">{row.original.code}</span>
      ),
    },
    {
      accessorKey: 'projectName',
      header: 'Proyecto',
    },
    {
      accessorKey: 'supplierName',
      header: 'Proveedor',
    },
    {
      accessorKey: 'orderDate',
      header: 'Fecha',
      cell: ({ row }) => formatDate(row.original.orderDate),
    },
    {
      accessorKey: 'deliveryDate',
      header: 'Entrega',
      cell: ({ row }) => formatDate(row.original.deliveryDate),
    },
    {
      accessorKey: 'total',
      header: 'Total',
      cell: ({ row }) => formatCurrency(row.original.total),
    },
    {
      accessorKey: 'status',
      header: 'Estado',
      cell: ({ row }) => <PurchaseOrderStatusBadge status={row.original.status} />,
    },
    {
      id: 'actions',
      cell: ({ row }) => (
        <div className="flex gap-2">
          <Button size="sm" variant="ghost">
            <Eye className="h-4 w-4" />
          </Button>
          <Button size="sm" variant="ghost">
            <Download className="h-4 w-4" />
          </Button>
          {row.original.status === 'approved' && (
            <Button size="sm" variant="ghost">
              <Send className="h-4 w-4" />
            </Button>
          )}
        </div>
      ),
    },
  ];

  return (
    <div className="space-y-4">
      <DataTable columns={columns} data={purchaseOrders} />
    </div>
  );
};

PDF Generation Utility

// src/features/purchase-orders/utils/pdfGenerator.ts
import jsPDF from 'jspdf';
import autoTable from 'jspdf-autotable';
import { formatCurrency, formatDate } from '@/shared/lib/utils';
import type { PurchaseOrder } from '../api/types';

export const generatePurchaseOrderPDF = (po: PurchaseOrder): jsPDF => {
  const doc = new jsPDF();

  // Header
  doc.setFontSize(18);
  doc.text('ORDEN DE COMPRA', 105, 20, { align: 'center' });

  doc.setFontSize(12);
  doc.text(`Folio: ${po.code}`, 105, 28, { align: 'center' });

  // Supplier info
  doc.setFontSize(10);
  doc.text('PROVEEDOR:', 20, 45);
  doc.setFontSize(9);
  doc.text(po.supplierName, 20, 52);

  // Delivery info
  doc.setFontSize(10);
  doc.text(`Fecha de Orden: ${formatDate(po.orderDate)}`, 20, 65);
  doc.text(`Fecha de Entrega: ${formatDate(po.deliveryDate)}`, 20, 72);
  doc.text(`Lugar de Entrega: ${po.deliveryAddress}`, 20, 79);

  // Items table
  autoTable(doc, {
    startY: 90,
    head: [['#', 'Descripción', 'Cant', 'Unidad', 'P.U.', 'Subtotal']],
    body: po.items.map((item, i) => [
      i + 1,
      item.description,
      item.quantity.toFixed(2),
      item.unit,
      formatCurrency(item.unitPrice),
      formatCurrency(item.subtotal),
    ]),
    foot: [
      ['', '', '', '', 'Subtotal:', formatCurrency(po.subtotal)],
      ['', '', '', '', `IVA (16%):`, formatCurrency(po.tax)],
      ['', '', '', '', 'TOTAL:', formatCurrency(po.total)],
    ],
  });

  // Payment terms
  const finalY = (doc as any).lastAutoTable.finalY + 10;
  doc.setFontSize(9);
  doc.text(`Condiciones de Pago: ${po.paymentTerms || `${po.paymentTermsDays} días`}`, 20, finalY);

  return doc;
};

4.3 Warehouses Module

Responsabilidad: Gestión de almacenes y niveles de inventario.

Components

// src/features/warehouses/components/WarehouseSelector.tsx
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/shared/components/ui/select';
import { useWarehouses } from '../hooks/useWarehouses';

interface WarehouseSelectorProps {
  projectId?: string;
  value?: string;
  onChange: (warehouseId: string) => void;
}

export const WarehouseSelector: React.FC<WarehouseSelectorProps> = ({
  projectId,
  value,
  onChange,
}) => {
  const { data: warehouses, isLoading } = useWarehouses({ projectId });

  return (
    <Select value={value} onValueChange={onChange} disabled={isLoading}>
      <SelectTrigger>
        <SelectValue placeholder="Seleccionar almacén" />
      </SelectTrigger>
      <SelectContent>
        {warehouses?.map((warehouse) => (
          <SelectItem key={warehouse.id} value={warehouse.id}>
            {warehouse.name} - {warehouse.location}
          </SelectItem>
        ))}
      </SelectContent>
    </Select>
  );
};

// src/features/warehouses/components/StockLevelCard.tsx
import { Card, CardContent, CardHeader, CardTitle } from '@/shared/components/ui/card';
import { Badge } from '@/shared/components/ui/badge';
import { AlertTriangle, TrendingDown, TrendingUp } from 'lucide-react';
import { formatNumber } from '@/shared/lib/utils';

interface StockLevelCardProps {
  material: {
    code: string;
    description: string;
    currentStock: number;
    unit: string;
    minLevel: number;
    maxLevel: number;
  };
}

export const StockLevelCard: React.FC<StockLevelCardProps> = ({ material }) => {
  const isLowStock = material.currentStock <= material.minLevel;
  const isOverStock = material.currentStock >= material.maxLevel;

  return (
    <Card className={isLowStock ? 'border-red-500' : ''}>
      <CardHeader className="flex flex-row items-center justify-between pb-2">
        <CardTitle className="text-sm font-medium">
          {material.code}
        </CardTitle>
        {isLowStock && (
          <Badge variant="destructive" className="flex items-center gap-1">
            <AlertTriangle className="h-3 w-3" />
            Stock Bajo
          </Badge>
        )}
      </CardHeader>
      <CardContent>
        <p className="text-xs text-muted-foreground mb-2">
          {material.description}
        </p>
        <div className="flex items-center justify-between">
          <div>
            <p className="text-2xl font-bold">
              {formatNumber(material.currentStock)}
            </p>
            <p className="text-xs text-muted-foreground">{material.unit}</p>
          </div>
          <div className="text-right text-xs">
            <div className="flex items-center gap-1 text-muted-foreground">
              <TrendingDown className="h-3 w-3" />
              Min: {material.minLevel}
            </div>
            <div className="flex items-center gap-1 text-muted-foreground">
              <TrendingUp className="h-3 w-3" />
              Max: {material.maxLevel}
            </div>
          </div>
        </div>
      </CardContent>
    </Card>
  );
};

4.4 Inventory Module

Responsabilidad: Gestión de movimientos de inventario (entradas, salidas, transferencias, ajustes).

Store (Zustand)

// src/features/inventory/store/inventoryStore.ts
import { create } from 'zustand';
import { devtools } from 'zustand/middleware';

interface InventoryMovement {
  id: string;
  type: 'entry' | 'exit' | 'transfer' | 'adjustment';
  warehouseId: string;
  warehouseName: string;
  date: Date;
  materialId: string;
  materialCode: string;
  materialDescription: string;
  quantity: number;
  unit: string;
  unitCost: number;
  totalCost: number;
  sourceType?: string;
  sourceId?: string;
  destinationWarehouseId?: string;
  notes?: string;
  createdBy: string;
  createdAt: Date;
}

interface InventoryState {
  movements: InventoryMovement[];
  selectedMovement: InventoryMovement | null;
  isLoading: boolean;
  filters: {
    warehouseId?: string;
    type?: string;
    dateFrom?: Date;
    dateTo?: Date;
  };

  setMovements: (movements: InventoryMovement[]) => void;
  setSelectedMovement: (movement: InventoryMovement | null) => void;
  setLoading: (loading: boolean) => void;
  setFilters: (filters: Partial<InventoryState['filters']>) => void;
  resetFilters: () => void;
  getFilteredMovements: () => InventoryMovement[];
}

export const useInventoryStore = create<InventoryState>()(
  devtools(
    (set, get) => ({
      movements: [],
      selectedMovement: null,
      isLoading: false,
      filters: {},

      setMovements: (movements) => set({ movements }),
      setSelectedMovement: (movement) => set({ selectedMovement: movement }),
      setLoading: (loading) => set({ isLoading: loading }),
      setFilters: (filters) => set((state) => ({ filters: { ...state.filters, ...filters } })),
      resetFilters: () => set({ filters: {} }),

      getFilteredMovements: () => {
        const { movements, filters } = get();

        return movements.filter((mov) => {
          if (filters.warehouseId && mov.warehouseId !== filters.warehouseId) return false;
          if (filters.type && mov.type !== filters.type) return false;
          if (filters.dateFrom && new Date(mov.date) < filters.dateFrom) return false;
          if (filters.dateTo && new Date(mov.date) > filters.dateTo) return false;
          return true;
        });
      },
    }),
    { name: 'inventory-store' }
  )
);

Components

// src/features/inventory/components/InventoryMovementForm.tsx
import { useState } from 'react';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/shared/components/ui/tabs';
import { InventoryEntryForm } from './InventoryEntryForm';
import { InventoryExitForm } from './InventoryExitForm';
import { InventoryTransferForm } from './InventoryTransferForm';
import { InventoryAdjustmentForm } from './InventoryAdjustmentForm';

type MovementType = 'entry' | 'exit' | 'transfer' | 'adjustment';

interface InventoryMovementFormProps {
  onSuccess?: () => void;
  onCancel?: () => void;
}

export const InventoryMovementForm: React.FC<InventoryMovementFormProps> = ({
  onSuccess,
  onCancel,
}) => {
  const [activeTab, setActiveTab] = useState<MovementType>('entry');

  return (
    <Tabs value={activeTab} onValueChange={(v) => setActiveTab(v as MovementType)}>
      <TabsList className="grid w-full grid-cols-4">
        <TabsTrigger value="entry">Entrada</TabsTrigger>
        <TabsTrigger value="exit">Salida</TabsTrigger>
        <TabsTrigger value="transfer">Transferencia</TabsTrigger>
        <TabsTrigger value="adjustment">Ajuste</TabsTrigger>
      </TabsList>

      <TabsContent value="entry">
        <InventoryEntryForm onSuccess={onSuccess} onCancel={onCancel} />
      </TabsContent>

      <TabsContent value="exit">
        <InventoryExitForm onSuccess={onSuccess} onCancel={onCancel} />
      </TabsContent>

      <TabsContent value="transfer">
        <InventoryTransferForm onSuccess={onSuccess} onCancel={onCancel} />
      </TabsContent>

      <TabsContent value="adjustment">
        <InventoryAdjustmentForm onSuccess={onSuccess} onCancel={onCancel} />
      </TabsContent>
    </Tabs>
  );
};

5. Integración con App Móvil MOB-002

Endpoints Compartidos

La aplicación web y la app móvil comparten los mismos endpoints REST API:

// Endpoints compartidos entre Web y Mobile
const SHARED_ENDPOINTS = {
  // Inventory Movements
  'POST /api/inventory/entries': 'Registro de entradas (usado por almacenistas)',
  'POST /api/inventory/exits': 'Registro de salidas (usado por almacenistas)',
  'POST /api/inventory/transfers': 'Transferencias entre almacenes',
  'GET /api/inventory/stock/:warehouseId': 'Consulta de existencias',
  'GET /api/inventory/movements': 'Historial de movimientos',

  // Purchase Orders
  'GET /api/purchase-orders/pending-receipts': 'OCs pendientes de recibir',
  'POST /api/purchase-orders/:id/receipts': 'Registro de recepción',

  // Materials
  'GET /api/materials/:code': 'Búsqueda de material por código/barcode',
  'GET /api/materials/search': 'Búsqueda de materiales',

  // Warehouses
  'GET /api/warehouses/:id': 'Detalle de almacén',
  'GET /api/warehouses/:id/locations': 'Ubicaciones del almacén',
};

Data Sync Strategy

// src/shared/lib/sync.ts
export interface SyncStrategy {
  // Mobile app envía datos en cola cuando recupera conectividad
  syncQueue: {
    endpoint: string;
    method: 'POST' | 'PUT' | 'DELETE';
    data: any;
    timestamp: Date;
  }[];

  // Conflictos se resuelven con timestamp (last-write-wins)
  resolveConflict: (local: any, remote: any) => any;
}

// Ejemplo: Mobile registra entrada offline
// 1. Guarda en WatermelonDB local
// 2. Agrega a syncQueue
// 3. Al recuperar conexión, ejecuta POST /api/inventory/entries
// 4. Si hay conflicto (ej. material ya no existe), notifica al usuario

Notificaciones Push

// Web escucha eventos de Socket.io para actualizar UI en tiempo real
// Mobile recibe notificaciones FCM

// Eventos compartidos:
const PUSH_EVENTS = {
  'inventory.entry.created': 'Nueva entrada registrada',
  'inventory.exit.approved': 'Salida aprobada',
  'purchase-order.received': 'Material recibido',
  'stock.low-level': 'Alerta de stock bajo',
};

6. Testing Strategy

Unit Tests (Vitest)

// src/features/requisitions/components/RequisitionForm.test.tsx
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { RequisitionForm } from './RequisitionForm';

describe('RequisitionForm', () => {
  it('should render form fields', () => {
    render(<RequisitionForm />);

    expect(screen.getByLabelText('Proyecto')).toBeInTheDocument();
    expect(screen.getByLabelText('Fecha Requerida')).toBeInTheDocument();
    expect(screen.getByLabelText('Urgencia')).toBeInTheDocument();
  });

  it('should validate required fields', async () => {
    render(<RequisitionForm />);

    const submitButton = screen.getByText('Enviar a Aprobación');
    await userEvent.click(submitButton);

    await waitFor(() => {
      expect(screen.getByText('Debe seleccionar un proyecto')).toBeInTheDocument();
    });
  });

  it('should submit form successfully', async () => {
    const onSuccess = vi.fn();
    render(<RequisitionForm onSuccess={onSuccess} />);

    // Fill form...

    const submitButton = screen.getByText('Enviar a Aprobación');
    await userEvent.click(submitButton);

    await waitFor(() => {
      expect(onSuccess).toHaveBeenCalled();
    });
  });
});

Integration Tests

// src/features/requisitions/hooks/useRequisitions.test.ts
import { renderHook, waitFor } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { useRequisitions } from './useRequisitions';

const createWrapper = () => {
  const queryClient = new QueryClient();
  return ({ children }: { children: React.ReactNode }) => (
    <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
  );
};

describe('useRequisitions', () => {
  it('should fetch requisitions', async () => {
    const { result } = renderHook(() => useRequisitions(), {
      wrapper: createWrapper(),
    });

    await waitFor(() => expect(result.current.isSuccess).toBe(true));

    expect(result.current.data).toBeDefined();
    expect(Array.isArray(result.current.data)).toBe(true);
  });
});

7. Performance Optimization

Code Splitting

// src/App.tsx
import { lazy, Suspense } from 'react';
import { BrowserRouter, Routes, Route } from 'react-router-dom';

// Lazy load feature modules
const RequisitionsModule = lazy(() => import('./features/requisitions/routes'));
const PurchaseOrdersModule = lazy(() => import('./features/purchase-orders/routes'));
const WarehousesModule = lazy(() => import('./features/warehouses/routes'));
const InventoryModule = lazy(() => import('./features/inventory/routes'));

export const App = () => (
  <BrowserRouter>
    <Suspense fallback={<div>Loading...</div>}>
      <Routes>
        <Route path="/requisitions/*" element={<RequisitionsModule />} />
        <Route path="/purchase-orders/*" element={<PurchaseOrdersModule />} />
        <Route path="/warehouses/*" element={<WarehousesModule />} />
        <Route path="/inventory/*" element={<InventoryModule />} />
      </Routes>
    </Suspense>
  </BrowserRouter>
);

React Query Optimization

// src/shared/lib/queryClient.ts
import { QueryClient } from '@tanstack/react-query';

export const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      staleTime: 5 * 60 * 1000, // 5 minutes
      cacheTime: 10 * 60 * 1000, // 10 minutes
      refetchOnWindowFocus: false,
      retry: 1,
    },
  },
});

Virtual Scrolling for Large Tables

// src/features/purchase-orders/components/PurchaseOrderList.tsx
import { useVirtualizer } from '@tanstack/react-virtual';

export const PurchaseOrderList: React.FC = () => {
  const parentRef = useRef<HTMLDivElement>(null);
  const purchaseOrders = usePurchaseOrdersStore((state) => state.purchaseOrders);

  const virtualizer = useVirtualizer({
    count: purchaseOrders.length,
    getScrollElement: () => parentRef.current,
    estimateSize: () => 60,
  });

  return (
    <div ref={parentRef} style={{ height: '600px', overflow: 'auto' }}>
      <div style={{ height: `${virtualizer.getTotalSize()}px` }}>
        {virtualizer.getVirtualItems().map((virtualRow) => (
          <div
            key={virtualRow.index}
            style={{
              position: 'absolute',
              top: 0,
              left: 0,
              width: '100%',
              height: `${virtualRow.size}px`,
              transform: `translateY(${virtualRow.start}px)`,
            }}
          >
            <PurchaseOrderRow order={purchaseOrders[virtualRow.index]} />
          </div>
        ))}
      </div>
    </div>
  );
};

8. Security Considerations

Authentication & Authorization

// src/shared/hooks/useAuth.ts
export const useAuth = () => {
  const token = localStorage.getItem('auth_token');

  // Axios interceptor for adding token to requests
  apiClient.interceptors.request.use((config) => {
    if (token) {
      config.headers.Authorization = `Bearer ${token}`;
    }
    return config;
  });
};

// src/shared/hooks/usePermissions.ts
export const usePermissions = () => {
  const user = useAuthStore((state) => state.user);

  const can = (permission: string) => {
    return user?.permissions.includes(permission) ?? false;
  };

  return { can };
};

// Usage in components
const { can } = usePermissions();

{can('requisitions.approve') && (
  <Button onClick={handleApprove}>Aprobar</Button>
)}

Input Validation

// All forms use Zod schemas for validation
// Example: Prevent XSS attacks
const materialSchema = z.object({
  description: z.string()
    .min(1, 'Descripción es requerida')
    .max(500, 'Descripción muy larga')
    .refine(
      (val) => !/<script|javascript:/i.test(val),
      'Contenido no permitido'
    ),
});

9. Deployment & Build

Environment Variables

// src/config/env.ts
export const env = {
  API_URL: import.meta.env.VITE_API_URL || 'http://localhost:3000/api',
  WS_URL: import.meta.env.VITE_WS_URL || 'ws://localhost:3000',
  UPLOAD_MAX_SIZE: parseInt(import.meta.env.VITE_UPLOAD_MAX_SIZE || '10485760'), // 10MB
};

Build Configuration

// vite.config.ts
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import path from 'path';

export default defineConfig({
  plugins: [react()],
  resolve: {
    alias: {
      '@': path.resolve(__dirname, './src'),
    },
  },
  build: {
    rollupOptions: {
      output: {
        manualChunks: {
          'vendor': ['react', 'react-dom', 'react-router-dom'],
          'ui': ['@radix-ui/react-dialog', '@radix-ui/react-select'],
          'forms': ['react-hook-form', 'zod'],
          'tables': ['@tanstack/react-table'],
          'query': ['@tanstack/react-query'],
        },
      },
    },
  },
});

10. Documentación de Componentes

Component Documentation (Storybook - Opcional)

// src/features/warehouses/components/StockLevelCard.stories.tsx
import type { Meta, StoryObj } from '@storybook/react';
import { StockLevelCard } from './StockLevelCard';

const meta: Meta<typeof StockLevelCard> = {
  title: 'Warehouses/StockLevelCard',
  component: StockLevelCard,
  tags: ['autodocs'],
};

export default meta;
type Story = StoryObj<typeof StockLevelCard>;

export const Normal: Story = {
  args: {
    material: {
      code: 'MAT-001',
      description: 'Cemento gris 50kg',
      currentStock: 150,
      unit: 'bultos',
      minLevel: 50,
      maxLevel: 300,
    },
  },
};

export const LowStock: Story = {
  args: {
    material: {
      code: 'MAT-002',
      description: 'Varilla 3/8"',
      currentStock: 20,
      unit: 'piezas',
      minLevel: 50,
      maxLevel: 200,
    },
  },
};

11. Conclusiones

Este documento define la arquitectura e implementación completa del frontend para el módulo MAI-004 (Compras e Inventarios). Los puntos clave son:

Arquitectura:

  • Feature-based structure para mejor organización
  • Zustand para state management local
  • TanStack Query para server state
  • Componentes reutilizables con shadcn/ui

Integración:

  • Endpoints REST compartidos con app móvil MOB-002
  • WebSockets para notificaciones en tiempo real
  • Estrategia de sync offline-first para mobile

Calidad:

  • TypeScript strict mode
  • Validación con Zod
  • Testing con Vitest
  • Code splitting y optimizaciones de performance

Seguridad:

  • Autenticación JWT
  • RBAC para permisos
  • Validación de inputs
  • Sanitización de datos

Estado: Ready for Implementation Próximos Pasos:

  1. Configurar proyecto Vite + React
  2. Implementar feature modules en orden: requisitions → purchase-orders → warehouses → inventory
  3. Desarrollar componentes compartidos
  4. Implementar tests unitarios e integración
  5. Integración con backend NestJS
  6. Pruebas E2E con app móvil MOB-002