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:
- Configurar proyecto Vite + React
- Implementar feature modules en orden: requisitions → purchase-orders → warehouses → inventory
- Desarrollar componentes compartidos
- Implementar tests unitarios e integración
- Integración con backend NestJS
- Pruebas E2E con app móvil MOB-002