# ET-COST-001-FRONTEND: Implementación Frontend - Presupuestos y Control de Costos **Épica:** MAI-003 - Presupuestos y Control de Costos **Versión:** 1.0 **Fecha:** 2025-12-06 **Stack:** React 18 + TypeScript + Vite + Zustand + Chart.js + TanStack Table --- ## 1. Arquitectura Frontend ### 1.1 Estructura de Módulos ``` src/ ├── features/ │ └── budgets/ │ ├── concept-catalog/ # Catálogo de conceptos │ │ ├── components/ │ │ │ ├── ConceptCatalogList.tsx │ │ │ ├── ConceptFilters.tsx │ │ │ ├── ConceptTable.tsx │ │ │ ├── CreateConceptModal.tsx │ │ │ ├── EditConceptModal.tsx │ │ │ ├── BulkUpdatePricesModal.tsx │ │ │ └── ConceptDetailsDrawer.tsx │ │ ├── hooks/ │ │ │ ├── useConceptCatalog.ts │ │ │ └── useConceptFilters.ts │ │ └── stores/ │ │ └── conceptCatalogStore.ts │ │ │ ├── budgets/ # Gestión de presupuestos │ │ ├── components/ │ │ │ ├── BudgetList.tsx │ │ │ ├── BudgetEditor.tsx │ │ │ ├── BudgetTree.tsx │ │ │ ├── APUEditor.tsx │ │ │ ├── ItemBreakdown.tsx │ │ │ └── BudgetSummary.tsx │ │ ├── hooks/ │ │ │ ├── useBudget.ts │ │ │ └── useBudgetTree.ts │ │ └── stores/ │ │ └── budgetStore.ts │ │ │ ├── cost-control/ # Control de costos │ │ ├── components/ │ │ │ ├── CostControlDashboard.tsx │ │ │ ├── CurveSChart.tsx │ │ │ ├── VarianceTable.tsx │ │ │ ├── ProgressOverview.tsx │ │ │ └── AlertsPanel.tsx │ │ ├── hooks/ │ │ │ ├── useCostControl.ts │ │ │ └── useCurveS.ts │ │ └── stores/ │ │ └── costControlStore.ts │ │ │ └── profitability/ # Análisis de rentabilidad │ ├── components/ │ │ ├── ProfitabilityDashboard.tsx │ │ ├── ProfitMarginChart.tsx │ │ ├── ROIAnalysis.tsx │ │ └── CostBreakdownChart.tsx │ ├── hooks/ │ │ └── useProfitability.ts │ └── stores/ │ └── profitabilityStore.ts │ ├── services/ │ └── api/ │ ├── conceptCatalogApi.ts │ ├── budgetApi.ts │ ├── costControlApi.ts │ └── profitabilityApi.ts │ └── types/ └── budgets.ts ``` --- ## 2. Types y DTOs ### 2.1 Types Core ```typescript // src/types/budgets.ts export enum ConceptType { MATERIAL = 'material', LABOR = 'labor', EQUIPMENT = 'equipment', COMPOSITE = 'composite', } export enum ConceptStatus { ACTIVE = 'active', DEPRECATED = 'deprecated', } export interface ConceptCatalog { id: string; code: string; name: string; description?: string; conceptType: ConceptType; category?: string; subcategory?: string; unit: string; // Pricing basePrice?: number; unitPrice?: number; unitPriceWithVAT?: number; includesVAT: boolean; currency: string; // Factors wasteFactor: number; // Composite concept components?: ComponentItem[]; laborCrew?: LaborCrewItem[]; indirectPercentage: number; financingPercentage: number; profitPercentage: number; additionalCharges: number; directCost?: number; // Metadata regionId?: string; preferredSupplierId?: string; technicalSpecs?: string; performance?: string; version: number; status: ConceptStatus; createdAt: string; updatedAt: string; } export interface ComponentItem { conceptId: string; quantity: number; unit: string; name?: string; unitPrice?: number; } export interface LaborCrewItem { category: string; quantity: number; dailyWage: number; fsr: number; // Factor de Salario Real } export interface Budget { id: string; code: string; name: string; description?: string; projectId: string; projectName?: string; version: number; status: 'draft' | 'approved' | 'active' | 'closed'; // Amounts totalDirectCost: number; totalIndirectCost: number; totalCost: number; totalPrice: number; totalPriceWithVAT: number; // Metadata validFrom: string; validUntil?: string; approvedBy?: string; approvedAt?: string; createdAt: string; updatedAt: string; } export interface BudgetItem { id: string; budgetId: string; parentId?: string; level: number; // Identification code: string; name: string; description?: string; itemType: 'chapter' | 'subchapter' | 'concept'; // Reference conceptId?: string; conceptCode?: string; // Quantities quantity: number; unit: string; unitPrice: number; totalPrice: number; // Breakdown (APU) breakdown?: ItemBreakdown; // Order orderIndex: number; // Children children?: BudgetItem[]; } export interface ItemBreakdown { materials: BreakdownItem[]; labor: BreakdownItem[]; equipment: BreakdownItem[]; indirectCost: number; financingCost: number; profitMargin: number; additionalCharges: number; } export interface BreakdownItem { conceptId: string; conceptCode: string; name: string; quantity: number; unit: string; unitPrice: number; totalPrice: number; } export interface CostControl { id: string; budgetId: string; periodType: 'weekly' | 'biweekly' | 'monthly'; // S-Curve data curveData: CurveDataPoint[]; // Current status budgetedTotal: number; spentToDate: number; committedAmount: number; projectedTotal: number; variance: number; variancePercentage: number; // Progress physicalProgress: number; financialProgress: number; // Alerts alerts: CostAlert[]; updatedAt: string; } export interface CurveDataPoint { period: string; // ISO date periodNumber: number; // Planned (Presupuestado) plannedCumulative: number; plannedPeriod: number; // Actual (Real) actualCumulative: number; actualPeriod: number; // Committed (Comprometido) committedCumulative: number; // Projected (Proyectado) projectedCumulative?: number; // Progress physicalProgress: number; financialProgress: number; // Variance variance: number; variancePercentage: number; } export interface CostAlert { id: string; severity: 'info' | 'warning' | 'critical'; type: 'budget_overrun' | 'schedule_delay' | 'variance_threshold'; message: string; value?: number; threshold?: number; createdAt: string; } export interface ProfitabilityAnalysis { budgetId: string; projectId: string; projectName: string; // Revenue totalRevenue: number; // Costs directCosts: number; indirectCosts: number; totalCosts: number; // Profit grossProfit: number; grossProfitMargin: number; // % netProfit: number; netProfitMargin: number; // % // ROI roi: number; // % // Breakdown by category costBreakdown: CostBreakdownItem[]; // Trends monthlyTrend: MonthlyProfitTrend[]; updatedAt: string; } export interface CostBreakdownItem { category: string; amount: number; percentage: number; color?: string; } export interface MonthlyProfitTrend { month: string; revenue: number; costs: number; profit: number; profitMargin: number; } ``` --- ## 3. Servicios API ### 3.1 Concept Catalog API ```typescript // src/services/api/conceptCatalogApi.ts import { apiClient } from './apiClient'; import type { ConceptCatalog } from '../../types/budgets'; export const conceptCatalogApi = { async getAll(filters?: { type?: string; category?: string; status?: string; search?: string; }): Promise { const params = new URLSearchParams(); if (filters?.type) params.append('type', filters.type); if (filters?.category) params.append('category', filters.category); if (filters?.status) params.append('status', filters.status); if (filters?.search) params.append('search', filters.search); const { data } = await apiClient.get(`/api/concept-catalog?${params.toString()}`); return data; }, async getById(id: string): Promise { const { data } = await apiClient.get(`/api/concept-catalog/${id}`); return data; }, async create(concept: Partial): Promise { const { data } = await apiClient.post('/api/concept-catalog', concept); return data; }, async update(id: string, concept: Partial): Promise { const { data } = await apiClient.put(`/api/concept-catalog/${id}`, concept); return data; }, async delete(id: string): Promise { await apiClient.delete(`/api/concept-catalog/${id}`); }, async bulkUpdatePrices(payload: { conceptIds: string[]; adjustmentType: 'percentage' | 'fixed'; adjustmentValue: number; reason: string; }): Promise { await apiClient.post('/api/concept-catalog/bulk-update-prices', payload); }, async calculatePrice(id: string): Promise<{ unitPrice: number }> { const { data } = await apiClient.post(`/api/concept-catalog/${id}/calculate-price`); return data; }, async getPriceHistory(id: string): Promise { const { data } = await apiClient.get(`/api/concept-catalog/${id}/price-history`); return data; }, }; ``` ### 3.2 Budget API ```typescript // src/services/api/budgetApi.ts import { apiClient } from './apiClient'; import type { Budget, BudgetItem } from '../../types/budgets'; export const budgetApi = { async getAll(filters?: { projectId?: string; status?: string; }): Promise { const params = new URLSearchParams(); if (filters?.projectId) params.append('projectId', filters.projectId); if (filters?.status) params.append('status', filters.status); const { data } = await apiClient.get(`/api/budgets?${params.toString()}`); return data; }, async getById(id: string): Promise { const { data } = await apiClient.get(`/api/budgets/${id}`); return data; }, async create(budget: Partial): Promise { const { data } = await apiClient.post('/api/budgets', budget); return data; }, async update(id: string, budget: Partial): Promise { const { data } = await apiClient.put(`/api/budgets/${id}`, budget); return data; }, async approve(id: string): Promise { const { data } = await apiClient.post(`/api/budgets/${id}/approve`); return data; }, async getItems(budgetId: string): Promise { const { data } = await apiClient.get(`/api/budgets/${budgetId}/items`); return data; }, async createItem(budgetId: string, item: Partial): Promise { const { data } = await apiClient.post(`/api/budgets/${budgetId}/items`, item); return data; }, async updateItem(budgetId: string, itemId: string, item: Partial): Promise { const { data } = await apiClient.put(`/api/budgets/${budgetId}/items/${itemId}`, item); return data; }, async deleteItem(budgetId: string, itemId: string): Promise { await apiClient.delete(`/api/budgets/${budgetId}/items/${itemId}`); }, async reorderItems(budgetId: string, items: { id: string; orderIndex: number }[]): Promise { await apiClient.post(`/api/budgets/${budgetId}/items/reorder`, { items }); }, async exportToExcel(id: string): Promise { const { data } = await apiClient.get(`/api/budgets/${id}/export/excel`, { responseType: 'blob', }); return data; }, }; ``` ### 3.3 Cost Control API ```typescript // src/services/api/costControlApi.ts import { apiClient } from './apiClient'; import type { CostControl, CurveDataPoint } from '../../types/budgets'; export const costControlApi = { async getCostControl(budgetId: string): Promise { const { data } = await apiClient.get(`/api/budgets/${budgetId}/cost-control`); return data; }, async updateActuals(budgetId: string, period: string, amount: number): Promise { const { data } = await apiClient.post(`/api/budgets/${budgetId}/cost-control/actuals`, { period, amount, }); return data; }, async updateProgress(budgetId: string, physicalProgress: number): Promise { const { data } = await apiClient.post(`/api/budgets/${budgetId}/cost-control/progress`, { physicalProgress, }); return data; }, async getCurveData(budgetId: string): Promise { const { data } = await apiClient.get(`/api/budgets/${budgetId}/cost-control/curve`); return data; }, async recalculate(budgetId: string): Promise { const { data } = await apiClient.post(`/api/budgets/${budgetId}/cost-control/recalculate`); return data; }, }; ``` ### 3.4 Profitability API ```typescript // src/services/api/profitabilityApi.ts import { apiClient } from './apiClient'; import type { ProfitabilityAnalysis } from '../../types/budgets'; export const profitabilityApi = { async getAnalysis(budgetId: string): Promise { const { data } = await apiClient.get(`/api/budgets/${budgetId}/profitability`); return data; }, async getByProject(projectId: string): Promise { const { data } = await apiClient.get(`/api/projects/${projectId}/profitability`); return data; }, async exportReport(budgetId: string): Promise { const { data } = await apiClient.get(`/api/budgets/${budgetId}/profitability/export`, { responseType: 'blob', }); return data; }, }; ``` --- ## 4. Stores Zustand ### 4.1 Concept Catalog Store ```typescript // src/features/budgets/concept-catalog/stores/conceptCatalogStore.ts import { create } from 'zustand'; import { devtools } from 'zustand/middleware'; import { conceptCatalogApi } from '../../../../services/api/conceptCatalogApi'; import type { ConceptCatalog } from '../../../../types/budgets'; interface ConceptCatalogState { concepts: ConceptCatalog[]; selectedConcept: ConceptCatalog | null; loading: boolean; error: string | null; // Actions fetchConcepts: (filters?: any) => Promise; fetchConceptById: (id: string) => Promise; createConcept: (data: Partial) => Promise; updateConcept: (id: string, data: Partial) => Promise; deleteConcept: (id: string) => Promise; bulkUpdatePrices: (data: any) => Promise; calculatePrice: (id: string) => Promise; setSelectedConcept: (concept: ConceptCatalog | null) => void; clearError: () => void; } export const useConceptCatalogStore = create()( devtools( (set, get) => ({ concepts: [], selectedConcept: null, loading: false, error: null, fetchConcepts: async (filters) => { set({ loading: true, error: null }); try { const concepts = await conceptCatalogApi.getAll(filters); set({ concepts, loading: false }); } catch (error: any) { set({ error: error.message, loading: false }); } }, fetchConceptById: async (id) => { set({ loading: true, error: null }); try { const concept = await conceptCatalogApi.getById(id); set({ selectedConcept: concept, loading: false }); } catch (error: any) { set({ error: error.message, loading: false }); } }, createConcept: async (data) => { set({ loading: true, error: null }); try { const newConcept = await conceptCatalogApi.create(data); set((state) => ({ concepts: [...state.concepts, newConcept], loading: false, })); } catch (error: any) { set({ error: error.message, loading: false }); throw error; } }, updateConcept: async (id, data) => { set({ loading: true, error: null }); try { const updated = await conceptCatalogApi.update(id, data); set((state) => ({ concepts: state.concepts.map((c) => (c.id === id ? updated : c)), selectedConcept: state.selectedConcept?.id === id ? updated : state.selectedConcept, loading: false, })); } catch (error: any) { set({ error: error.message, loading: false }); throw error; } }, deleteConcept: async (id) => { set({ loading: true, error: null }); try { await conceptCatalogApi.delete(id); set((state) => ({ concepts: state.concepts.filter((c) => c.id !== id), loading: false, })); } catch (error: any) { set({ error: error.message, loading: false }); throw error; } }, bulkUpdatePrices: async (data) => { set({ loading: true, error: null }); try { await conceptCatalogApi.bulkUpdatePrices(data); // Refrescar conceptos await get().fetchConcepts(); } catch (error: any) { set({ error: error.message, loading: false }); throw error; } }, calculatePrice: async (id) => { try { const { unitPrice } = await conceptCatalogApi.calculatePrice(id); // Actualizar concepto en el estado set((state) => ({ concepts: state.concepts.map((c) => c.id === id ? { ...c, unitPrice } : c ), })); return unitPrice; } catch (error: any) { set({ error: error.message }); throw error; } }, setSelectedConcept: (concept) => { set({ selectedConcept: concept }); }, clearError: () => { set({ error: null }); }, }), { name: 'ConceptCatalogStore' } ) ); ``` ### 4.2 Budget Store ```typescript // src/features/budgets/budgets/stores/budgetStore.ts import { create } from 'zustand'; import { devtools } from 'zustand/middleware'; import { budgetApi } from '../../../../services/api/budgetApi'; import type { Budget, BudgetItem } from '../../../../types/budgets'; interface BudgetState { budgets: Budget[]; currentBudget: Budget | null; budgetItems: BudgetItem[]; loading: boolean; error: string | null; // Actions fetchBudgets: (filters?: any) => Promise; fetchBudgetById: (id: string) => Promise; createBudget: (data: Partial) => Promise; updateBudget: (id: string, data: Partial) => Promise; approveBudget: (id: string) => Promise; fetchBudgetItems: (budgetId: string) => Promise; createBudgetItem: (budgetId: string, item: Partial) => Promise; updateBudgetItem: (budgetId: string, itemId: string, item: Partial) => Promise; deleteBudgetItem: (budgetId: string, itemId: string) => Promise; reorderItems: (budgetId: string, items: { id: string; orderIndex: number }[]) => Promise; setCurrentBudget: (budget: Budget | null) => void; clearError: () => void; } export const useBudgetStore = create()( devtools( (set, get) => ({ budgets: [], currentBudget: null, budgetItems: [], loading: false, error: null, fetchBudgets: async (filters) => { set({ loading: true, error: null }); try { const budgets = await budgetApi.getAll(filters); set({ budgets, loading: false }); } catch (error: any) { set({ error: error.message, loading: false }); } }, fetchBudgetById: async (id) => { set({ loading: true, error: null }); try { const budget = await budgetApi.getById(id); set({ currentBudget: budget, loading: false }); } catch (error: any) { set({ error: error.message, loading: false }); } }, createBudget: async (data) => { set({ loading: true, error: null }); try { const newBudget = await budgetApi.create(data); set((state) => ({ budgets: [...state.budgets, newBudget], loading: false, })); } catch (error: any) { set({ error: error.message, loading: false }); throw error; } }, updateBudget: async (id, data) => { set({ loading: true, error: null }); try { const updated = await budgetApi.update(id, data); set((state) => ({ budgets: state.budgets.map((b) => (b.id === id ? updated : b)), currentBudget: state.currentBudget?.id === id ? updated : state.currentBudget, loading: false, })); } catch (error: any) { set({ error: error.message, loading: false }); throw error; } }, approveBudget: async (id) => { set({ loading: true, error: null }); try { const approved = await budgetApi.approve(id); set((state) => ({ budgets: state.budgets.map((b) => (b.id === id ? approved : b)), currentBudget: state.currentBudget?.id === id ? approved : state.currentBudget, loading: false, })); } catch (error: any) { set({ error: error.message, loading: false }); throw error; } }, fetchBudgetItems: async (budgetId) => { set({ loading: true, error: null }); try { const items = await budgetApi.getItems(budgetId); set({ budgetItems: items, loading: false }); } catch (error: any) { set({ error: error.message, loading: false }); } }, createBudgetItem: async (budgetId, item) => { set({ loading: true, error: null }); try { const newItem = await budgetApi.createItem(budgetId, item); set((state) => ({ budgetItems: [...state.budgetItems, newItem], loading: false, })); } catch (error: any) { set({ error: error.message, loading: false }); throw error; } }, updateBudgetItem: async (budgetId, itemId, item) => { set({ loading: true, error: null }); try { const updated = await budgetApi.updateItem(budgetId, itemId, item); set((state) => ({ budgetItems: state.budgetItems.map((i) => (i.id === itemId ? updated : i)), loading: false, })); } catch (error: any) { set({ error: error.message, loading: false }); throw error; } }, deleteBudgetItem: async (budgetId, itemId) => { set({ loading: true, error: null }); try { await budgetApi.deleteItem(budgetId, itemId); set((state) => ({ budgetItems: state.budgetItems.filter((i) => i.id !== itemId), loading: false, })); } catch (error: any) { set({ error: error.message, loading: false }); throw error; } }, reorderItems: async (budgetId, items) => { try { await budgetApi.reorderItems(budgetId, items); await get().fetchBudgetItems(budgetId); } catch (error: any) { set({ error: error.message }); throw error; } }, setCurrentBudget: (budget) => { set({ currentBudget: budget }); }, clearError: () => { set({ error: null }); }, }), { name: 'BudgetStore' } ) ); ``` ### 4.3 Cost Control Store ```typescript // src/features/budgets/cost-control/stores/costControlStore.ts import { create } from 'zustand'; import { devtools } from 'zustand/middleware'; import { costControlApi } from '../../../../services/api/costControlApi'; import type { CostControl, CurveDataPoint } from '../../../../types/budgets'; interface CostControlState { costControl: CostControl | null; curveData: CurveDataPoint[]; loading: boolean; error: string | null; // Actions fetchCostControl: (budgetId: string) => Promise; fetchCurveData: (budgetId: string) => Promise; updateActuals: (budgetId: string, period: string, amount: number) => Promise; updateProgress: (budgetId: string, physicalProgress: number) => Promise; recalculate: (budgetId: string) => Promise; clearError: () => void; } export const useCostControlStore = create()( devtools( (set, get) => ({ costControl: null, curveData: [], loading: false, error: null, fetchCostControl: async (budgetId) => { set({ loading: true, error: null }); try { const costControl = await costControlApi.getCostControl(budgetId); set({ costControl, loading: false }); } catch (error: any) { set({ error: error.message, loading: false }); } }, fetchCurveData: async (budgetId) => { set({ loading: true, error: null }); try { const curveData = await costControlApi.getCurveData(budgetId); set({ curveData, loading: false }); } catch (error: any) { set({ error: error.message, loading: false }); } }, updateActuals: async (budgetId, period, amount) => { set({ loading: true, error: null }); try { const updated = await costControlApi.updateActuals(budgetId, period, amount); set({ costControl: updated, loading: false }); } catch (error: any) { set({ error: error.message, loading: false }); throw error; } }, updateProgress: async (budgetId, physicalProgress) => { set({ loading: true, error: null }); try { const updated = await costControlApi.updateProgress(budgetId, physicalProgress); set({ costControl: updated, loading: false }); } catch (error: any) { set({ error: error.message, loading: false }); throw error; } }, recalculate: async (budgetId) => { set({ loading: true, error: null }); try { const updated = await costControlApi.recalculate(budgetId); set({ costControl: updated, loading: false }); } catch (error: any) { set({ error: error.message, loading: false }); throw error; } }, clearError: () => { set({ error: null }); }, }), { name: 'CostControlStore' } ) ); ``` ### 4.4 Profitability Store ```typescript // src/features/budgets/profitability/stores/profitabilityStore.ts import { create } from 'zustand'; import { devtools } from 'zustand/middleware'; import { profitabilityApi } from '../../../../services/api/profitabilityApi'; import type { ProfitabilityAnalysis } from '../../../../types/budgets'; interface ProfitabilityState { analysis: ProfitabilityAnalysis | null; loading: boolean; error: string | null; // Actions fetchAnalysis: (budgetId: string) => Promise; fetchByProject: (projectId: string) => Promise; clearError: () => void; } export const useProfitabilityStore = create()( devtools( (set) => ({ analysis: null, loading: false, error: null, fetchAnalysis: async (budgetId) => { set({ loading: true, error: null }); try { const analysis = await profitabilityApi.getAnalysis(budgetId); set({ analysis, loading: false }); } catch (error: any) { set({ error: error.message, loading: false }); } }, fetchByProject: async (projectId) => { set({ loading: true, error: null }); try { const analysis = await profitabilityApi.getByProject(projectId); set({ analysis, loading: false }); } catch (error: any) { set({ error: error.message, loading: false }); } }, clearError: () => { set({ error: null }); }, }), { name: 'ProfitabilityStore' } ) ); ``` --- ## 5. Componentes Principales ### 5.1 ConceptCatalogList ```typescript // src/features/budgets/concept-catalog/components/ConceptCatalogList.tsx import React, { useEffect, useState } from 'react'; import { useConceptCatalogStore } from '../stores/conceptCatalogStore'; import { ConceptTable } from './ConceptTable'; import { ConceptFilters } from './ConceptFilters'; import { CreateConceptModal } from './CreateConceptModal'; import { EditConceptModal } from './EditConceptModal'; import { BulkUpdatePricesModal } from './BulkUpdatePricesModal'; import { ConceptDetailsDrawer } from './ConceptDetailsDrawer'; import { Button } from '../../../../components/ui/Button'; import { PlusIcon, RefreshIcon } from '../../../../components/icons'; export function ConceptCatalogList() { const { concepts, loading, error, fetchConcepts, setSelectedConcept, selectedConcept, } = useConceptCatalogStore(); const [filters, setFilters] = useState({ type: '', category: '', status: 'active', search: '', }); const [selectedIds, setSelectedIds] = useState([]); const [showCreateModal, setShowCreateModal] = useState(false); const [showEditModal, setShowEditModal] = useState(false); const [showBulkUpdateModal, setShowBulkUpdateModal] = useState(false); const [showDetailsDrawer, setShowDetailsDrawer] = useState(false); useEffect(() => { fetchConcepts(filters); }, [filters, fetchConcepts]); const handleRefresh = () => { fetchConcepts(filters); }; const handleRowClick = (concept: ConceptCatalog) => { setSelectedConcept(concept); setShowDetailsDrawer(true); }; const handleEdit = (concept: ConceptCatalog) => { setSelectedConcept(concept); setShowEditModal(true); }; return (
{/* Header */}

Catálogo de Conceptos

Gestión de conceptos: materiales, mano de obra, equipo y conceptos compuestos (APU)

{selectedIds.length > 0 && ( )}
{/* Error Alert */} {error && (
{error}
)} {/* Filters */} {/* Table */} {/* Modals */} {showCreateModal && ( setShowCreateModal(false)} /> )} {showEditModal && selectedConcept && ( { setShowEditModal(false); setSelectedConcept(null); }} /> )} {showBulkUpdateModal && ( { setShowBulkUpdateModal(false); setSelectedIds([]); }} /> )} {showDetailsDrawer && selectedConcept && ( { setShowDetailsDrawer(false); setSelectedConcept(null); }} onEdit={() => { setShowDetailsDrawer(false); setShowEditModal(true); }} /> )}
); } ``` ### 5.2 ConceptTable (TanStack Table) ```typescript // src/features/budgets/concept-catalog/components/ConceptTable.tsx import React, { useMemo } from 'react'; import { useReactTable, getCoreRowModel, getSortedRowModel, getFilteredRowModel, getPaginationRowModel, flexRender, createColumnHelper, } from '@tanstack/react-table'; import type { ConceptCatalog } from '../../../../types/budgets'; import { formatCurrency } from '../../../../utils/format'; import { Badge } from '../../../../components/ui/Badge'; import { Checkbox } from '../../../../components/ui/Checkbox'; import { IconButton } from '../../../../components/ui/IconButton'; import { EditIcon, TrashIcon } from '../../../../components/icons'; const columnHelper = createColumnHelper(); interface ConceptTableProps { concepts: ConceptCatalog[]; loading: boolean; selectedIds: string[]; onSelectionChange: (ids: string[]) => void; onRowClick: (concept: ConceptCatalog) => void; onEdit: (concept: ConceptCatalog) => void; } export function ConceptTable({ concepts, loading, selectedIds, onSelectionChange, onRowClick, onEdit, }: ConceptTableProps) { const columns = useMemo( () => [ columnHelper.display({ id: 'select', header: ({ table }) => ( ), cell: ({ row }) => ( e.stopPropagation()} /> ), size: 40, }), columnHelper.accessor('code', { header: 'Código', cell: (info) => ( {info.getValue()} ), size: 120, }), columnHelper.accessor('name', { header: 'Nombre', cell: (info) => (
{info.getValue()}
{info.row.original.description && (
{info.row.original.description}
)}
), size: 300, }), columnHelper.accessor('conceptType', { header: 'Tipo', cell: (info) => { const typeLabels = { material: 'Material', labor: 'M. Obra', equipment: 'Equipo', composite: 'Compuesto', }; const typeColors = { material: 'blue', labor: 'green', equipment: 'orange', composite: 'purple', }; return ( {typeLabels[info.getValue()]} ); }, size: 100, }), columnHelper.accessor('category', { header: 'Categoría', cell: (info) => info.getValue() || '-', size: 150, }), columnHelper.accessor('unit', { header: 'Unidad', cell: (info) => ( {info.getValue()} ), size: 80, }), columnHelper.accessor('unitPrice', { header: 'Precio Unitario', cell: (info) => { const price = info.getValue() || info.row.original.basePrice || 0; return ( {formatCurrency(price)} ); }, size: 130, }), columnHelper.accessor('status', { header: 'Estado', cell: (info) => ( {info.getValue() === 'active' ? 'Activo' : 'Deprecado'} ), size: 100, }), columnHelper.display({ id: 'actions', header: 'Acciones', cell: ({ row }) => (
e.stopPropagation()}> } onClick={() => onEdit(row.original)} title="Editar" />
), size: 80, }), ], [onEdit] ); const table = useReactTable({ data: concepts, columns, getCoreRowModel: getCoreRowModel(), getSortedRowModel: getSortedRowModel(), getFilteredRowModel: getFilteredRowModel(), getPaginationRowModel: getPaginationRowModel(), state: { rowSelection: selectedIds.reduce((acc, id) => ({ ...acc, [id]: true }), {}), }, onRowSelectionChange: (updater) => { const newSelection = typeof updater === 'function' ? updater(table.getState().rowSelection) : updater; onSelectionChange(Object.keys(newSelection)); }, enableRowSelection: true, initialState: { pagination: { pageSize: 50, }, }, }); if (loading) { return
Cargando conceptos...
; } return (
{table.getHeaderGroups().map((headerGroup) => ( {headerGroup.headers.map((header) => ( ))} ))} {table.getRowModel().rows.map((row) => ( onRowClick(row.original)} className="cursor-pointer hover:bg-gray-50" > {row.getVisibleCells().map((cell) => ( ))} ))}
{flexRender( header.column.columnDef.header, header.getContext() )} {header.column.getIsSorted() && ( {header.column.getIsSorted() === 'asc' ? ' ↑' : ' ↓'} )}
{flexRender(cell.column.columnDef.cell, cell.getContext())}
{/* Pagination */}
Mostrando {table.getState().pagination.pageIndex * table.getState().pagination.pageSize + 1} a{' '} {Math.min( (table.getState().pagination.pageIndex + 1) * table.getState().pagination.pageSize, concepts.length )}{' '} de {concepts.length} conceptos
); } ``` ### 5.3 APUEditor (Análisis de Precio Unitario) ```typescript // src/features/budgets/budgets/components/APUEditor.tsx import React, { useState, useEffect } from 'react'; import { useConceptCatalogStore } from '../../concept-catalog/stores/conceptCatalogStore'; import type { ComponentItem, LaborCrewItem } from '../../../../types/budgets'; import { Button } from '../../../../components/ui/Button'; import { Input } from '../../../../components/ui/Input'; import { Select } from '../../../../components/ui/Select'; import { formatCurrency } from '../../../../utils/format'; import { PlusIcon, TrashIcon } from '../../../../components/icons'; interface APUEditorProps { components: ComponentItem[]; laborCrew: LaborCrewItem[]; factors: { indirectPercentage: number; financingPercentage: number; profitPercentage: number; additionalCharges: number; }; onChange: (data: { components: ComponentItem[]; laborCrew: LaborCrewItem[]; factors: any; }) => void; } export function APUEditor({ components, laborCrew, factors, onChange }: APUEditorProps) { const { concepts } = useConceptCatalogStore(); const [localComponents, setLocalComponents] = useState(components || []); const [localLaborCrew, setLocalLaborCrew] = useState(laborCrew || []); const [localFactors, setLocalFactors] = useState(factors); // Calcular costos const materialCost = localComponents.reduce( (sum, item) => sum + (item.quantity * (item.unitPrice || 0)), 0 ); const laborCost = localLaborCrew.reduce( (sum, item) => sum + (item.quantity * item.dailyWage * item.fsr), 0 ); const directCost = materialCost + laborCost; const indirectCost = directCost * (localFactors.indirectPercentage / 100); const financingCost = (directCost + indirectCost) * (localFactors.financingPercentage / 100); const subtotal = directCost + indirectCost + financingCost; const profit = subtotal * (localFactors.profitPercentage / 100); const additionalCost = subtotal * (localFactors.additionalCharges / 100); const unitPrice = subtotal + profit + additionalCost; useEffect(() => { onChange({ components: localComponents, laborCrew: localLaborCrew, factors: localFactors, }); }, [localComponents, localLaborCrew, localFactors]); const handleAddComponent = () => { setLocalComponents([ ...localComponents, { conceptId: '', quantity: 0, unit: '', name: '', unitPrice: 0 }, ]); }; const handleRemoveComponent = (index: number) => { setLocalComponents(localComponents.filter((_, i) => i !== index)); }; const handleComponentChange = (index: number, field: string, value: any) => { const updated = [...localComponents]; updated[index] = { ...updated[index], [field]: value }; // Si cambió el conceptId, buscar el concepto y actualizar precio if (field === 'conceptId') { const concept = concepts.find((c) => c.id === value); if (concept) { updated[index].name = concept.name; updated[index].unit = concept.unit; updated[index].unitPrice = concept.unitPrice || concept.basePrice || 0; } } setLocalComponents(updated); }; const handleAddLabor = () => { setLocalLaborCrew([ ...localLaborCrew, { category: '', quantity: 0, dailyWage: 0, fsr: 1.5 }, ]); }; const handleRemoveLabor = (index: number) => { setLocalLaborCrew(localLaborCrew.filter((_, i) => i !== index)); }; const handleLaborChange = (index: number, field: string, value: any) => { const updated = [...localLaborCrew]; updated[index] = { ...updated[index], [field]: value }; setLocalLaborCrew(updated); }; const handleFactorChange = (field: string, value: number) => { setLocalFactors({ ...localFactors, [field]: value }); }; return (

Análisis de Precio Unitario (APU)

{/* Materiales y Equipos */}

Materiales y Equipos

{localComponents.map((component, index) => (
handleComponentChange(index, 'quantity', parseFloat(e.target.value))} placeholder="Cantidad" />
handleComponentChange(index, 'unitPrice', parseFloat(e.target.value))} placeholder="P.U." />
{formatCurrency(component.quantity * (component.unitPrice || 0))}
} onClick={() => handleRemoveComponent(index)} variant="danger" />
))}
Subtotal Materiales: {formatCurrency(materialCost)}
{/* Mano de Obra */}

Mano de Obra

{localLaborCrew.map((labor, index) => (
handleLaborChange(index, 'category', e.target.value)} placeholder="Categoría (ej: Oficial)" />
handleLaborChange(index, 'quantity', parseFloat(e.target.value))} placeholder="Jornales" />
handleLaborChange(index, 'dailyWage', parseFloat(e.target.value))} placeholder="Salario/día" />
handleLaborChange(index, 'fsr', parseFloat(e.target.value))} placeholder="FSR" />
{formatCurrency(labor.quantity * labor.dailyWage * labor.fsr)}
} onClick={() => handleRemoveLabor(index)} variant="danger" />
))}
Subtotal M.O.: {formatCurrency(laborCost)}
{/* Factores */}

Factores de Costo

handleFactorChange('indirectPercentage', parseFloat(e.target.value))} />
handleFactorChange('financingPercentage', parseFloat(e.target.value))} />
handleFactorChange('profitPercentage', parseFloat(e.target.value))} />
handleFactorChange('additionalCharges', parseFloat(e.target.value))} />
{/* Resumen de Costos */}

Resumen de Costos

Costo Directo: {formatCurrency(directCost)}
Indirectos ({localFactors.indirectPercentage}%): {formatCurrency(indirectCost)}
Financiamiento ({localFactors.financingPercentage}%): {formatCurrency(financingCost)}
Utilidad ({localFactors.profitPercentage}%): {formatCurrency(profit)}
Cargos Adicionales ({localFactors.additionalCharges}%): {formatCurrency(additionalCost)}
Precio Unitario: {formatCurrency(unitPrice)}
); } ``` ### 5.4 BudgetTree ```typescript // src/features/budgets/budgets/components/BudgetTree.tsx import React, { useState } from 'react'; import type { BudgetItem } from '../../../../types/budgets'; import { formatCurrency } from '../../../../utils/format'; import { ChevronRightIcon, ChevronDownIcon, EditIcon } from '../../../../components/icons'; import { IconButton } from '../../../../components/ui/IconButton'; interface BudgetTreeProps { items: BudgetItem[]; onEdit?: (item: BudgetItem) => void; onSelect?: (item: BudgetItem) => void; } export function BudgetTree({ items, onEdit, onSelect }: BudgetTreeProps) { const [expandedIds, setExpandedIds] = useState>(new Set()); const toggleExpand = (id: string) => { const newExpanded = new Set(expandedIds); if (newExpanded.has(id)) { newExpanded.delete(id); } else { newExpanded.add(id); } setExpandedIds(newExpanded); }; const renderItem = (item: BudgetItem, level: number = 0) => { const isExpanded = expandedIds.has(item.id); const hasChildren = item.children && item.children.length > 0; return (
{hasChildren ? ( ) : (
)}
onSelect?.(item)} >
{item.code}
{item.name} {item.description && ( {item.description} )}
{item.itemType === 'concept' && ( <>
{item.quantity} {item.unit}
{formatCurrency(item.unitPrice)}
)}
{formatCurrency(item.totalPrice)}
{onEdit && ( } onClick={(e) => { e.stopPropagation(); onEdit(item); }} size="sm" /> )}
{hasChildren && isExpanded && (
{item.children!.map((child) => renderItem(child, level + 1))}
)}
); }; return (
Partida
Cantidad
P.U.
Importe
{items.map((item) => renderItem(item))}
); } ``` ### 5.5 CurveSChart (Chart.js) ```typescript // src/features/budgets/cost-control/components/CurveSChart.tsx import React, { useEffect, useRef } from 'react'; import { Chart as ChartJS, CategoryScale, LinearScale, PointElement, LineElement, Title, Tooltip, Legend, Filler, } from 'chart.js'; import { Line } from 'react-chartjs-2'; import type { CurveDataPoint } from '../../../../types/budgets'; import { formatCurrency } from '../../../../utils/format'; ChartJS.register( CategoryScale, LinearScale, PointElement, LineElement, Title, Tooltip, Legend, Filler ); interface CurveSChartProps { data: CurveDataPoint[]; } export function CurveSChart({ data }: CurveSChartProps) { const chartData = { labels: data.map((d) => { const date = new Date(d.period); return `${date.getMonth() + 1}/${date.getFullYear()}`; }), datasets: [ { label: 'Presupuestado', data: data.map((d) => d.plannedCumulative), borderColor: 'rgb(59, 130, 246)', backgroundColor: 'rgba(59, 130, 246, 0.1)', borderWidth: 2, tension: 0.4, fill: false, }, { label: 'Real', data: data.map((d) => d.actualCumulative), borderColor: 'rgb(16, 185, 129)', backgroundColor: 'rgba(16, 185, 129, 0.1)', borderWidth: 2, tension: 0.4, fill: false, }, { label: 'Comprometido', data: data.map((d) => d.committedCumulative), borderColor: 'rgb(251, 146, 60)', backgroundColor: 'rgba(251, 146, 60, 0.1)', borderWidth: 2, borderDash: [5, 5], tension: 0.4, fill: false, }, { label: 'Proyectado', data: data.map((d) => d.projectedCumulative), borderColor: 'rgb(168, 85, 247)', backgroundColor: 'rgba(168, 85, 247, 0.1)', borderWidth: 2, borderDash: [10, 5], tension: 0.4, fill: false, }, ], }; const options = { responsive: true, maintainAspectRatio: false, interaction: { mode: 'index' as const, intersect: false, }, plugins: { legend: { position: 'top' as const, }, title: { display: true, text: 'Curva S - Control de Costos', font: { size: 16, weight: 'bold' as const, }, }, tooltip: { callbacks: { label: function (context: any) { let label = context.dataset.label || ''; if (label) { label += ': '; } label += formatCurrency(context.parsed.y); return label; }, }, }, }, scales: { y: { beginAtZero: true, ticks: { callback: function (value: any) { return formatCurrency(value); }, }, }, }, }; return (
); } ``` ### 5.6 VarianceTable ```typescript // src/features/budgets/cost-control/components/VarianceTable.tsx import React from 'react'; import type { CurveDataPoint } from '../../../../types/budgets'; import { formatCurrency, formatPercentage } from '../../../../utils/format'; interface VarianceTableProps { data: CurveDataPoint[]; } export function VarianceTable({ data }: VarianceTableProps) { return (

Análisis de Variaciones

{data.map((point, index) => { const variance = point.variance; const varianceClass = variance > 0 ? 'text-red-600' : 'text-green-600'; return ( ); })}
Período Presupuestado Real Variación % Variación Avance Físico Avance Financiero
{new Date(point.period).toLocaleDateString('es-MX', { month: 'short', year: 'numeric', })} {formatCurrency(point.plannedCumulative)} {formatCurrency(point.actualCumulative)} {formatCurrency(Math.abs(variance))} {variance > 0 ? ' ↑' : ' ↓'} {formatPercentage(point.variancePercentage)}
{formatPercentage(point.physicalProgress)}
{formatPercentage(point.financialProgress)}
); } ``` ### 5.7 ProfitabilityDashboard ```typescript // src/features/budgets/profitability/components/ProfitabilityDashboard.tsx import React, { useEffect } from 'react'; import { useProfitabilityStore } from '../stores/profitabilityStore'; import { ProfitMarginChart } from './ProfitMarginChart'; import { CostBreakdownChart } from './CostBreakdownChart'; import { ROIAnalysis } from './ROIAnalysis'; import { formatCurrency, formatPercentage } from '../../../../utils/format'; import { Card } from '../../../../components/ui/Card'; interface ProfitabilityDashboardProps { budgetId: string; } export function ProfitabilityDashboard({ budgetId }: ProfitabilityDashboardProps) { const { analysis, loading, fetchAnalysis } = useProfitabilityStore(); useEffect(() => { fetchAnalysis(budgetId); }, [budgetId, fetchAnalysis]); if (loading) { return
Cargando análisis de rentabilidad...
; } if (!analysis) { return
No hay datos disponibles
; } return (

Análisis de Rentabilidad

{/* KPIs */}
Ingresos Totales
{formatCurrency(analysis.totalRevenue)}
Costos Totales
{formatCurrency(analysis.totalCosts)}
Utilidad Neta
{formatCurrency(analysis.netProfit)}
Margen Neto
{formatPercentage(analysis.netProfitMargin)}
{/* Charts */}
{/* ROI Analysis */}
); } ``` --- ## 6. Utilidades y Helpers ### 6.1 Format Utils ```typescript // src/utils/format.ts export function formatCurrency(value: number, currency: string = 'MXN'): string { return new Intl.NumberFormat('es-MX', { style: 'currency', currency, minimumFractionDigits: 2, maximumFractionDigits: 2, }).format(value); } export function formatPercentage(value: number, decimals: number = 2): string { return `${value.toFixed(decimals)}%`; } export function formatNumber(value: number, decimals: number = 2): string { return new Intl.NumberFormat('es-MX', { minimumFractionDigits: decimals, maximumFractionDigits: decimals, }).format(value); } ``` --- ## 7. Configuración Vite ```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'), '@features': path.resolve(__dirname, './src/features'), '@components': path.resolve(__dirname, './src/components'), '@services': path.resolve(__dirname, './src/services'), '@types': path.resolve(__dirname, './src/types'), '@utils': path.resolve(__dirname, './src/utils'), }, }, server: { port: 3000, proxy: { '/api': { target: 'http://localhost:4000', changeOrigin: true, }, }, }, build: { sourcemap: true, rollupOptions: { output: { manualChunks: { 'react-vendor': ['react', 'react-dom', 'react-router-dom'], 'chart-vendor': ['chart.js', 'react-chartjs-2'], 'table-vendor': ['@tanstack/react-table'], 'zustand-vendor': ['zustand'], }, }, }, }, }); ``` --- ## 8. Package.json ```json { "name": "erp-construccion-frontend", "version": "1.0.0", "type": "module", "scripts": { "dev": "vite", "build": "tsc && vite build", "preview": "vite preview", "lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0", "test": "vitest" }, "dependencies": { "react": "^18.3.1", "react-dom": "^18.3.1", "react-router-dom": "^6.22.0", "zustand": "^4.5.0", "@tanstack/react-table": "^8.11.0", "chart.js": "^4.4.1", "react-chartjs-2": "^5.2.0", "axios": "^1.6.5", "clsx": "^2.1.0", "date-fns": "^3.3.0" }, "devDependencies": { "@types/react": "^18.2.55", "@types/react-dom": "^18.2.19", "@typescript-eslint/eslint-plugin": "^6.21.0", "@typescript-eslint/parser": "^6.21.0", "@vitejs/plugin-react": "^4.2.1", "autoprefixer": "^10.4.17", "eslint": "^8.56.0", "eslint-plugin-react-hooks": "^4.6.0", "eslint-plugin-react-refresh": "^0.4.5", "postcss": "^8.4.35", "tailwindcss": "^3.4.1", "typescript": "^5.3.3", "vite": "^5.1.0", "vitest": "^1.2.2" } } ``` --- ## 9. Testing ### 9.1 Store Tests ```typescript // src/features/budgets/concept-catalog/stores/conceptCatalogStore.test.ts import { renderHook, act, waitFor } from '@testing-library/react'; import { useConceptCatalogStore } from './conceptCatalogStore'; import { conceptCatalogApi } from '../../../../services/api/conceptCatalogApi'; jest.mock('../../../../services/api/conceptCatalogApi'); describe('ConceptCatalogStore', () => { beforeEach(() => { useConceptCatalogStore.setState({ concepts: [], loading: false, error: null, }); }); it('debe cargar conceptos exitosamente', async () => { const mockConcepts = [ { id: '1', code: 'MAT-2025-001', name: 'Cemento CPC 30R', conceptType: 'material', unit: 'ton', basePrice: 4300, }, ]; (conceptCatalogApi.getAll as jest.Mock).mockResolvedValue(mockConcepts); const { result } = renderHook(() => useConceptCatalogStore()); await act(async () => { await result.current.fetchConcepts(); }); await waitFor(() => { expect(result.current.concepts).toEqual(mockConcepts); expect(result.current.loading).toBe(false); }); }); it('debe crear un concepto nuevo', async () => { const newConcept = { name: 'Acero fy=4200', conceptType: 'material', unit: 'kg', basePrice: 18.50, }; const createdConcept = { ...newConcept, id: '2', code: 'MAT-2025-002' }; (conceptCatalogApi.create as jest.Mock).mockResolvedValue(createdConcept); const { result } = renderHook(() => useConceptCatalogStore()); await act(async () => { await result.current.createConcept(newConcept); }); await waitFor(() => { expect(result.current.concepts).toContainEqual(createdConcept); }); }); }); ``` --- ## 10. Performance y Optimizaciones ### 10.1 Estrategias de Optimización 1. **Code Splitting**: Dividir el bundle por feature modules 2. **Lazy Loading**: Cargar componentes bajo demanda 3. **Memoization**: Usar React.memo, useMemo, useCallback 4. **Virtual Scrolling**: Para tablas grandes (react-window) 5. **Debounce**: En búsquedas y filtros 6. **Caching**: Con React Query o SWR para datos API ### 10.2 Lazy Loading Example ```typescript // src/App.tsx import { lazy, Suspense } from 'react'; import { BrowserRouter, Routes, Route } from 'react-router-dom'; const ConceptCatalogList = lazy(() => import('./features/budgets/concept-catalog/components/ConceptCatalogList')); const BudgetEditor = lazy(() => import('./features/budgets/budgets/components/BudgetEditor')); const CostControlDashboard = lazy(() => import('./features/budgets/cost-control/components/CostControlDashboard')); function App() { return ( Cargando...
}> } /> } /> } /> ); } ``` --- ## 11. Deployment ### 11.1 Build para Producción ```bash # Build npm run build # Preview npm run preview ``` ### 11.2 Variables de Entorno ```env # .env.production VITE_API_URL=https://api.erp-construccion.com VITE_APP_VERSION=1.0.0 VITE_ENVIRONMENT=production ``` --- **Estado:** Ready for Implementation **Última actualización:** 2025-12-06