1567 lines
44 KiB
Markdown
1567 lines
44 KiB
Markdown
# 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<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
|
|
|
|
```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<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
|
|
|
|
```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<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)
|
|
|
|
```typescript
|
|
// 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
|
|
|
|
```typescript
|
|
// 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
|
|
|
|
```typescript
|
|
// 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
|
|
|
|
```typescript
|
|
// 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)
|
|
|
|
```typescript
|
|
// 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
|
|
|
|
```typescript
|
|
// 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:
|
|
|
|
```typescript
|
|
// 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
|
|
|
|
```typescript
|
|
// 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
|
|
|
|
```typescript
|
|
// 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)
|
|
|
|
```typescript
|
|
// 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
|
|
|
|
```typescript
|
|
// 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
|
|
|
|
```typescript
|
|
// 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
|
|
|
|
```typescript
|
|
// 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
|
|
|
|
```typescript
|
|
// 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
|
|
|
|
```typescript
|
|
// 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
|
|
|
|
```typescript
|
|
// 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
|
|
|
|
```typescript
|
|
// 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
|
|
|
|
```typescript
|
|
// 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)
|
|
|
|
```typescript
|
|
// 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
|