erp-construccion/docs/02-definicion-modulos/MAI-003-presupuestos-costos/especificaciones/ET-COST-001-frontend.md

72 KiB

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

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

// 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<ConceptCatalog[]> {
    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<ConceptCatalog> {
    const { data } = await apiClient.get(`/api/concept-catalog/${id}`);
    return data;
  },

  async create(concept: Partial<ConceptCatalog>): Promise<ConceptCatalog> {
    const { data } = await apiClient.post('/api/concept-catalog', concept);
    return data;
  },

  async update(id: string, concept: Partial<ConceptCatalog>): Promise<ConceptCatalog> {
    const { data } = await apiClient.put(`/api/concept-catalog/${id}`, concept);
    return data;
  },

  async delete(id: string): Promise<void> {
    await apiClient.delete(`/api/concept-catalog/${id}`);
  },

  async bulkUpdatePrices(payload: {
    conceptIds: string[];
    adjustmentType: 'percentage' | 'fixed';
    adjustmentValue: number;
    reason: string;
  }): Promise<void> {
    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<any[]> {
    const { data } = await apiClient.get(`/api/concept-catalog/${id}/price-history`);
    return data;
  },
};

3.2 Budget API

// 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<Budget[]> {
    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<Budget> {
    const { data } = await apiClient.get(`/api/budgets/${id}`);
    return data;
  },

  async create(budget: Partial<Budget>): Promise<Budget> {
    const { data } = await apiClient.post('/api/budgets', budget);
    return data;
  },

  async update(id: string, budget: Partial<Budget>): Promise<Budget> {
    const { data } = await apiClient.put(`/api/budgets/${id}`, budget);
    return data;
  },

  async approve(id: string): Promise<Budget> {
    const { data } = await apiClient.post(`/api/budgets/${id}/approve`);
    return data;
  },

  async getItems(budgetId: string): Promise<BudgetItem[]> {
    const { data } = await apiClient.get(`/api/budgets/${budgetId}/items`);
    return data;
  },

  async createItem(budgetId: string, item: Partial<BudgetItem>): Promise<BudgetItem> {
    const { data } = await apiClient.post(`/api/budgets/${budgetId}/items`, item);
    return data;
  },

  async updateItem(budgetId: string, itemId: string, item: Partial<BudgetItem>): Promise<BudgetItem> {
    const { data } = await apiClient.put(`/api/budgets/${budgetId}/items/${itemId}`, item);
    return data;
  },

  async deleteItem(budgetId: string, itemId: string): Promise<void> {
    await apiClient.delete(`/api/budgets/${budgetId}/items/${itemId}`);
  },

  async reorderItems(budgetId: string, items: { id: string; orderIndex: number }[]): Promise<void> {
    await apiClient.post(`/api/budgets/${budgetId}/items/reorder`, { items });
  },

  async exportToExcel(id: string): Promise<Blob> {
    const { data } = await apiClient.get(`/api/budgets/${id}/export/excel`, {
      responseType: 'blob',
    });
    return data;
  },
};

3.3 Cost Control API

// src/services/api/costControlApi.ts
import { apiClient } from './apiClient';
import type { CostControl, CurveDataPoint } from '../../types/budgets';

export const costControlApi = {
  async getCostControl(budgetId: string): Promise<CostControl> {
    const { data } = await apiClient.get(`/api/budgets/${budgetId}/cost-control`);
    return data;
  },

  async updateActuals(budgetId: string, period: string, amount: number): Promise<CostControl> {
    const { data } = await apiClient.post(`/api/budgets/${budgetId}/cost-control/actuals`, {
      period,
      amount,
    });
    return data;
  },

  async updateProgress(budgetId: string, physicalProgress: number): Promise<CostControl> {
    const { data } = await apiClient.post(`/api/budgets/${budgetId}/cost-control/progress`, {
      physicalProgress,
    });
    return data;
  },

  async getCurveData(budgetId: string): Promise<CurveDataPoint[]> {
    const { data } = await apiClient.get(`/api/budgets/${budgetId}/cost-control/curve`);
    return data;
  },

  async recalculate(budgetId: string): Promise<CostControl> {
    const { data } = await apiClient.post(`/api/budgets/${budgetId}/cost-control/recalculate`);
    return data;
  },
};

3.4 Profitability API

// src/services/api/profitabilityApi.ts
import { apiClient } from './apiClient';
import type { ProfitabilityAnalysis } from '../../types/budgets';

export const profitabilityApi = {
  async getAnalysis(budgetId: string): Promise<ProfitabilityAnalysis> {
    const { data } = await apiClient.get(`/api/budgets/${budgetId}/profitability`);
    return data;
  },

  async getByProject(projectId: string): Promise<ProfitabilityAnalysis> {
    const { data } = await apiClient.get(`/api/projects/${projectId}/profitability`);
    return data;
  },

  async exportReport(budgetId: string): Promise<Blob> {
    const { data } = await apiClient.get(`/api/budgets/${budgetId}/profitability/export`, {
      responseType: 'blob',
    });
    return data;
  },
};

4. Stores Zustand

4.1 Concept Catalog Store

// 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<void>;
  fetchConceptById: (id: string) => Promise<void>;
  createConcept: (data: Partial<ConceptCatalog>) => Promise<void>;
  updateConcept: (id: string, data: Partial<ConceptCatalog>) => Promise<void>;
  deleteConcept: (id: string) => Promise<void>;
  bulkUpdatePrices: (data: any) => Promise<void>;
  calculatePrice: (id: string) => Promise<number>;
  setSelectedConcept: (concept: ConceptCatalog | null) => void;
  clearError: () => void;
}

export const useConceptCatalogStore = create<ConceptCatalogState>()(
  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

// 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<void>;
  fetchBudgetById: (id: string) => Promise<void>;
  createBudget: (data: Partial<Budget>) => Promise<void>;
  updateBudget: (id: string, data: Partial<Budget>) => Promise<void>;
  approveBudget: (id: string) => Promise<void>;
  fetchBudgetItems: (budgetId: string) => Promise<void>;
  createBudgetItem: (budgetId: string, item: Partial<BudgetItem>) => Promise<void>;
  updateBudgetItem: (budgetId: string, itemId: string, item: Partial<BudgetItem>) => Promise<void>;
  deleteBudgetItem: (budgetId: string, itemId: string) => Promise<void>;
  reorderItems: (budgetId: string, items: { id: string; orderIndex: number }[]) => Promise<void>;
  setCurrentBudget: (budget: Budget | null) => void;
  clearError: () => void;
}

export const useBudgetStore = create<BudgetState>()(
  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

// 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<void>;
  fetchCurveData: (budgetId: string) => Promise<void>;
  updateActuals: (budgetId: string, period: string, amount: number) => Promise<void>;
  updateProgress: (budgetId: string, physicalProgress: number) => Promise<void>;
  recalculate: (budgetId: string) => Promise<void>;
  clearError: () => void;
}

export const useCostControlStore = create<CostControlState>()(
  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

// 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<void>;
  fetchByProject: (projectId: string) => Promise<void>;
  clearError: () => void;
}

export const useProfitabilityStore = create<ProfitabilityState>()(
  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

// 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<string[]>([]);
  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 (
    <div className="concept-catalog-page">
      {/* Header */}
      <div className="page-header">
        <div>
          <h1 className="text-2xl font-bold">Catálogo de Conceptos</h1>
          <p className="text-gray-600">
            Gestión de conceptos: materiales, mano de obra, equipo y conceptos compuestos (APU)
          </p>
        </div>
        <div className="flex gap-2">
          <Button
            variant="outline"
            onClick={handleRefresh}
            disabled={loading}
          >
            <RefreshIcon className="w-4 h-4" />
            Actualizar
          </Button>
          <Button
            variant="primary"
            onClick={() => setShowCreateModal(true)}
          >
            <PlusIcon className="w-4 h-4" />
            Nuevo Concepto
          </Button>
          {selectedIds.length > 0 && (
            <Button
              variant="secondary"
              onClick={() => setShowBulkUpdateModal(true)}
            >
              Actualizar Precios ({selectedIds.length})
            </Button>
          )}
        </div>
      </div>

      {/* Error Alert */}
      {error && (
        <div className="alert alert-error">
          {error}
        </div>
      )}

      {/* Filters */}
      <ConceptFilters
        filters={filters}
        onChange={setFilters}
      />

      {/* Table */}
      <ConceptTable
        concepts={concepts}
        loading={loading}
        selectedIds={selectedIds}
        onSelectionChange={setSelectedIds}
        onRowClick={handleRowClick}
        onEdit={handleEdit}
      />

      {/* Modals */}
      {showCreateModal && (
        <CreateConceptModal
          onClose={() => setShowCreateModal(false)}
        />
      )}

      {showEditModal && selectedConcept && (
        <EditConceptModal
          concept={selectedConcept}
          onClose={() => {
            setShowEditModal(false);
            setSelectedConcept(null);
          }}
        />
      )}

      {showBulkUpdateModal && (
        <BulkUpdatePricesModal
          conceptIds={selectedIds}
          onClose={() => {
            setShowBulkUpdateModal(false);
            setSelectedIds([]);
          }}
        />
      )}

      {showDetailsDrawer && selectedConcept && (
        <ConceptDetailsDrawer
          concept={selectedConcept}
          onClose={() => {
            setShowDetailsDrawer(false);
            setSelectedConcept(null);
          }}
          onEdit={() => {
            setShowDetailsDrawer(false);
            setShowEditModal(true);
          }}
        />
      )}
    </div>
  );
}

5.2 ConceptTable (TanStack Table)

// 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<ConceptCatalog>();

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 }) => (
          <Checkbox
            checked={table.getIsAllRowsSelected()}
            indeterminate={table.getIsSomeRowsSelected()}
            onChange={table.getToggleAllRowsSelectedHandler()}
          />
        ),
        cell: ({ row }) => (
          <Checkbox
            checked={row.getIsSelected()}
            onChange={row.getToggleSelectedHandler()}
            onClick={(e) => e.stopPropagation()}
          />
        ),
        size: 40,
      }),
      columnHelper.accessor('code', {
        header: 'Código',
        cell: (info) => (
          <span className="font-mono text-sm">{info.getValue()}</span>
        ),
        size: 120,
      }),
      columnHelper.accessor('name', {
        header: 'Nombre',
        cell: (info) => (
          <div>
            <div className="font-medium">{info.getValue()}</div>
            {info.row.original.description && (
              <div className="text-xs text-gray-500 truncate max-w-md">
                {info.row.original.description}
              </div>
            )}
          </div>
        ),
        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 (
            <Badge color={typeColors[info.getValue()]}>
              {typeLabels[info.getValue()]}
            </Badge>
          );
        },
        size: 100,
      }),
      columnHelper.accessor('category', {
        header: 'Categoría',
        cell: (info) => info.getValue() || '-',
        size: 150,
      }),
      columnHelper.accessor('unit', {
        header: 'Unidad',
        cell: (info) => (
          <span className="font-mono text-sm">{info.getValue()}</span>
        ),
        size: 80,
      }),
      columnHelper.accessor('unitPrice', {
        header: 'Precio Unitario',
        cell: (info) => {
          const price = info.getValue() || info.row.original.basePrice || 0;
          return (
            <span className="font-semibold">
              {formatCurrency(price)}
            </span>
          );
        },
        size: 130,
      }),
      columnHelper.accessor('status', {
        header: 'Estado',
        cell: (info) => (
          <Badge color={info.getValue() === 'active' ? 'green' : 'gray'}>
            {info.getValue() === 'active' ? 'Activo' : 'Deprecado'}
          </Badge>
        ),
        size: 100,
      }),
      columnHelper.display({
        id: 'actions',
        header: 'Acciones',
        cell: ({ row }) => (
          <div className="flex gap-1" onClick={(e) => e.stopPropagation()}>
            <IconButton
              icon={<EditIcon />}
              onClick={() => onEdit(row.original)}
              title="Editar"
            />
          </div>
        ),
        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 <div className="loading">Cargando conceptos...</div>;
  }

  return (
    <div className="table-container">
      <div className="overflow-x-auto">
        <table className="table">
          <thead>
            {table.getHeaderGroups().map((headerGroup) => (
              <tr key={headerGroup.id}>
                {headerGroup.headers.map((header) => (
                  <th
                    key={header.id}
                    style={{ width: header.getSize() }}
                    className={header.column.getCanSort() ? 'sortable' : ''}
                    onClick={header.column.getToggleSortingHandler()}
                  >
                    {flexRender(
                      header.column.columnDef.header,
                      header.getContext()
                    )}
                    {header.column.getIsSorted() && (
                      <span className="sort-indicator">
                        {header.column.getIsSorted() === 'asc' ? ' ↑' : ' ↓'}
                      </span>
                    )}
                  </th>
                ))}
              </tr>
            ))}
          </thead>
          <tbody>
            {table.getRowModel().rows.map((row) => (
              <tr
                key={row.id}
                onClick={() => onRowClick(row.original)}
                className="cursor-pointer hover:bg-gray-50"
              >
                {row.getVisibleCells().map((cell) => (
                  <td key={cell.id}>
                    {flexRender(cell.column.columnDef.cell, cell.getContext())}
                  </td>
                ))}
              </tr>
            ))}
          </tbody>
        </table>
      </div>

      {/* Pagination */}
      <div className="table-pagination">
        <div className="pagination-info">
          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
        </div>
        <div className="pagination-controls">
          <button
            onClick={() => table.previousPage()}
            disabled={!table.getCanPreviousPage()}
          >
            Anterior
          </button>
          <button
            onClick={() => table.nextPage()}
            disabled={!table.getCanNextPage()}
          >
            Siguiente
          </button>
        </div>
      </div>
    </div>
  );
}

5.3 APUEditor (Análisis de Precio Unitario)

// 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<ComponentItem[]>(components || []);
  const [localLaborCrew, setLocalLaborCrew] = useState<LaborCrewItem[]>(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 (
    <div className="apu-editor">
      <h3 className="text-lg font-semibold mb-4">Análisis de Precio Unitario (APU)</h3>

      {/* Materiales y Equipos */}
      <div className="section mb-6">
        <div className="section-header">
          <h4 className="font-medium">Materiales y Equipos</h4>
          <Button size="sm" variant="outline" onClick={handleAddComponent}>
            <PlusIcon className="w-4 h-4" />
            Agregar
          </Button>
        </div>

        <div className="space-y-2 mt-3">
          {localComponents.map((component, index) => (
            <div key={index} className="grid grid-cols-12 gap-2 items-center">
              <div className="col-span-5">
                <Select
                  value={component.conceptId}
                  onChange={(e) => handleComponentChange(index, 'conceptId', e.target.value)}
                  placeholder="Seleccionar concepto"
                >
                  <option value="">Seleccionar...</option>
                  {concepts
                    .filter((c) => c.conceptType === 'material' || c.conceptType === 'equipment')
                    .map((c) => (
                      <option key={c.id} value={c.id}>
                        {c.code} - {c.name}
                      </option>
                    ))}
                </Select>
              </div>
              <div className="col-span-2">
                <Input
                  type="number"
                  value={component.quantity}
                  onChange={(e) => handleComponentChange(index, 'quantity', parseFloat(e.target.value))}
                  placeholder="Cantidad"
                />
              </div>
              <div className="col-span-1">
                <Input
                  value={component.unit}
                  disabled
                  placeholder="Unidad"
                />
              </div>
              <div className="col-span-2">
                <Input
                  type="number"
                  value={component.unitPrice}
                  onChange={(e) => handleComponentChange(index, 'unitPrice', parseFloat(e.target.value))}
                  placeholder="P.U."
                />
              </div>
              <div className="col-span-1 text-right font-semibold">
                {formatCurrency(component.quantity * (component.unitPrice || 0))}
              </div>
              <div className="col-span-1">
                <IconButton
                  icon={<TrashIcon />}
                  onClick={() => handleRemoveComponent(index)}
                  variant="danger"
                />
              </div>
            </div>
          ))}
        </div>

        <div className="mt-2 text-right">
          <span className="text-sm font-medium">Subtotal Materiales: </span>
          <span className="font-bold">{formatCurrency(materialCost)}</span>
        </div>
      </div>

      {/* Mano de Obra */}
      <div className="section mb-6">
        <div className="section-header">
          <h4 className="font-medium">Mano de Obra</h4>
          <Button size="sm" variant="outline" onClick={handleAddLabor}>
            <PlusIcon className="w-4 h-4" />
            Agregar
          </Button>
        </div>

        <div className="space-y-2 mt-3">
          {localLaborCrew.map((labor, index) => (
            <div key={index} className="grid grid-cols-12 gap-2 items-center">
              <div className="col-span-4">
                <Input
                  value={labor.category}
                  onChange={(e) => handleLaborChange(index, 'category', e.target.value)}
                  placeholder="Categoría (ej: Oficial)"
                />
              </div>
              <div className="col-span-2">
                <Input
                  type="number"
                  step="0.01"
                  value={labor.quantity}
                  onChange={(e) => handleLaborChange(index, 'quantity', parseFloat(e.target.value))}
                  placeholder="Jornales"
                />
              </div>
              <div className="col-span-2">
                <Input
                  type="number"
                  value={labor.dailyWage}
                  onChange={(e) => handleLaborChange(index, 'dailyWage', parseFloat(e.target.value))}
                  placeholder="Salario/día"
                />
              </div>
              <div className="col-span-2">
                <Input
                  type="number"
                  step="0.01"
                  value={labor.fsr}
                  onChange={(e) => handleLaborChange(index, 'fsr', parseFloat(e.target.value))}
                  placeholder="FSR"
                />
              </div>
              <div className="col-span-1 text-right font-semibold">
                {formatCurrency(labor.quantity * labor.dailyWage * labor.fsr)}
              </div>
              <div className="col-span-1">
                <IconButton
                  icon={<TrashIcon />}
                  onClick={() => handleRemoveLabor(index)}
                  variant="danger"
                />
              </div>
            </div>
          ))}
        </div>

        <div className="mt-2 text-right">
          <span className="text-sm font-medium">Subtotal M.O.: </span>
          <span className="font-bold">{formatCurrency(laborCost)}</span>
        </div>
      </div>

      {/* Factores */}
      <div className="section mb-6">
        <h4 className="font-medium mb-3">Factores de Costo</h4>

        <div className="grid grid-cols-2 gap-4">
          <div>
            <label className="block text-sm mb-1">Indirectos (%)</label>
            <Input
              type="number"
              step="0.01"
              value={localFactors.indirectPercentage}
              onChange={(e) => handleFactorChange('indirectPercentage', parseFloat(e.target.value))}
            />
          </div>
          <div>
            <label className="block text-sm mb-1">Financiamiento (%)</label>
            <Input
              type="number"
              step="0.01"
              value={localFactors.financingPercentage}
              onChange={(e) => handleFactorChange('financingPercentage', parseFloat(e.target.value))}
            />
          </div>
          <div>
            <label className="block text-sm mb-1">Utilidad (%)</label>
            <Input
              type="number"
              step="0.01"
              value={localFactors.profitPercentage}
              onChange={(e) => handleFactorChange('profitPercentage', parseFloat(e.target.value))}
            />
          </div>
          <div>
            <label className="block text-sm mb-1">Cargos Adicionales (%)</label>
            <Input
              type="number"
              step="0.01"
              value={localFactors.additionalCharges}
              onChange={(e) => handleFactorChange('additionalCharges', parseFloat(e.target.value))}
            />
          </div>
        </div>
      </div>

      {/* Resumen de Costos */}
      <div className="section bg-gray-50 p-4 rounded">
        <h4 className="font-semibold mb-3">Resumen de Costos</h4>
        <div className="space-y-2">
          <div className="flex justify-between">
            <span>Costo Directo:</span>
            <span className="font-medium">{formatCurrency(directCost)}</span>
          </div>
          <div className="flex justify-between">
            <span>Indirectos ({localFactors.indirectPercentage}%):</span>
            <span className="font-medium">{formatCurrency(indirectCost)}</span>
          </div>
          <div className="flex justify-between">
            <span>Financiamiento ({localFactors.financingPercentage}%):</span>
            <span className="font-medium">{formatCurrency(financingCost)}</span>
          </div>
          <div className="flex justify-between">
            <span>Utilidad ({localFactors.profitPercentage}%):</span>
            <span className="font-medium">{formatCurrency(profit)}</span>
          </div>
          <div className="flex justify-between">
            <span>Cargos Adicionales ({localFactors.additionalCharges}%):</span>
            <span className="font-medium">{formatCurrency(additionalCost)}</span>
          </div>
          <div className="border-t pt-2 flex justify-between text-lg">
            <span className="font-bold">Precio Unitario:</span>
            <span className="font-bold text-blue-600">{formatCurrency(unitPrice)}</span>
          </div>
        </div>
      </div>
    </div>
  );
}

5.4 BudgetTree

// 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<Set<string>>(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 (
      <div key={item.id}>
        <div
          className={`
            budget-tree-item
            level-${level}
            ${item.itemType === 'chapter' ? 'chapter' : ''}
            ${item.itemType === 'concept' ? 'concept' : ''}
          `}
          style={{ paddingLeft: `${level * 1.5}rem` }}
        >
          <div className="item-header">
            {hasChildren ? (
              <button
                className="expand-button"
                onClick={() => toggleExpand(item.id)}
              >
                {isExpanded ? (
                  <ChevronDownIcon className="w-4 h-4" />
                ) : (
                  <ChevronRightIcon className="w-4 h-4" />
                )}
              </button>
            ) : (
              <div className="w-4" />
            )}

            <div
              className="item-content flex-1 cursor-pointer"
              onClick={() => onSelect?.(item)}
            >
              <div className="item-code font-mono text-sm">{item.code}</div>
              <div className="item-name flex-1">
                <span className={item.itemType === 'chapter' ? 'font-bold' : ''}>
                  {item.name}
                </span>
                {item.description && (
                  <span className="text-xs text-gray-500 ml-2">
                    {item.description}
                  </span>
                )}
              </div>
            </div>

            <div className="item-details flex items-center gap-4">
              {item.itemType === 'concept' && (
                <>
                  <div className="quantity">
                    <span className="font-medium">{item.quantity}</span>
                    <span className="text-xs text-gray-500 ml-1">{item.unit}</span>
                  </div>
                  <div className="unit-price text-sm">
                    {formatCurrency(item.unitPrice)}
                  </div>
                </>
              )}
              <div className="total-price font-semibold min-w-[120px] text-right">
                {formatCurrency(item.totalPrice)}
              </div>

              {onEdit && (
                <IconButton
                  icon={<EditIcon />}
                  onClick={(e) => {
                    e.stopPropagation();
                    onEdit(item);
                  }}
                  size="sm"
                />
              )}
            </div>
          </div>
        </div>

        {hasChildren && isExpanded && (
          <div className="children">
            {item.children!.map((child) => renderItem(child, level + 1))}
          </div>
        )}
      </div>
    );
  };

  return (
    <div className="budget-tree">
      <div className="tree-header">
        <div className="header-content">
          <div style={{ width: '2rem' }} />
          <div className="flex-1">Partida</div>
          <div className="header-details flex gap-4">
            <div className="w-24">Cantidad</div>
            <div className="w-32">P.U.</div>
            <div className="w-32 text-right">Importe</div>
            <div className="w-8" />
          </div>
        </div>
      </div>

      <div className="tree-body">
        {items.map((item) => renderItem(item))}
      </div>
    </div>
  );
}

5.5 CurveSChart (Chart.js)

// 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 (
    <div className="curve-s-chart" style={{ height: '500px' }}>
      <Line data={chartData} options={options} />
    </div>
  );
}

5.6 VarianceTable

// 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 (
    <div className="variance-table-container">
      <h3 className="text-lg font-semibold mb-4">Análisis de Variaciones</h3>

      <div className="overflow-x-auto">
        <table className="table">
          <thead>
            <tr>
              <th>Período</th>
              <th className="text-right">Presupuestado</th>
              <th className="text-right">Real</th>
              <th className="text-right">Variación</th>
              <th className="text-right">% Variación</th>
              <th className="text-right">Avance Físico</th>
              <th className="text-right">Avance Financiero</th>
            </tr>
          </thead>
          <tbody>
            {data.map((point, index) => {
              const variance = point.variance;
              const varianceClass = variance > 0 ? 'text-red-600' : 'text-green-600';

              return (
                <tr key={index}>
                  <td>
                    {new Date(point.period).toLocaleDateString('es-MX', {
                      month: 'short',
                      year: 'numeric',
                    })}
                  </td>
                  <td className="text-right font-mono">
                    {formatCurrency(point.plannedCumulative)}
                  </td>
                  <td className="text-right font-mono">
                    {formatCurrency(point.actualCumulative)}
                  </td>
                  <td className={`text-right font-mono font-semibold ${varianceClass}`}>
                    {formatCurrency(Math.abs(variance))}
                    {variance > 0 ? ' ↑' : ' ↓'}
                  </td>
                  <td className={`text-right font-mono ${varianceClass}`}>
                    {formatPercentage(point.variancePercentage)}
                  </td>
                  <td className="text-right">
                    <div className="flex items-center justify-end gap-2">
                      <div className="w-24 h-2 bg-gray-200 rounded-full overflow-hidden">
                        <div
                          className="h-full bg-blue-500"
                          style={{ width: `${point.physicalProgress}%` }}
                        />
                      </div>
                      <span className="text-sm">{formatPercentage(point.physicalProgress)}</span>
                    </div>
                  </td>
                  <td className="text-right">
                    <div className="flex items-center justify-end gap-2">
                      <div className="w-24 h-2 bg-gray-200 rounded-full overflow-hidden">
                        <div
                          className="h-full bg-green-500"
                          style={{ width: `${point.financialProgress}%` }}
                        />
                      </div>
                      <span className="text-sm">{formatPercentage(point.financialProgress)}</span>
                    </div>
                  </td>
                </tr>
              );
            })}
          </tbody>
        </table>
      </div>
    </div>
  );
}

5.7 ProfitabilityDashboard

// 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 <div className="loading">Cargando análisis de rentabilidad...</div>;
  }

  if (!analysis) {
    return <div className="no-data">No hay datos disponibles</div>;
  }

  return (
    <div className="profitability-dashboard">
      <h2 className="text-2xl font-bold mb-6">Análisis de Rentabilidad</h2>

      {/* KPIs */}
      <div className="grid grid-cols-4 gap-4 mb-6">
        <Card>
          <div className="text-sm text-gray-600">Ingresos Totales</div>
          <div className="text-2xl font-bold text-blue-600">
            {formatCurrency(analysis.totalRevenue)}
          </div>
        </Card>

        <Card>
          <div className="text-sm text-gray-600">Costos Totales</div>
          <div className="text-2xl font-bold text-orange-600">
            {formatCurrency(analysis.totalCosts)}
          </div>
        </Card>

        <Card>
          <div className="text-sm text-gray-600">Utilidad Neta</div>
          <div className="text-2xl font-bold text-green-600">
            {formatCurrency(analysis.netProfit)}
          </div>
        </Card>

        <Card>
          <div className="text-sm text-gray-600">Margen Neto</div>
          <div className="text-2xl font-bold text-purple-600">
            {formatPercentage(analysis.netProfitMargin)}
          </div>
        </Card>
      </div>

      {/* Charts */}
      <div className="grid grid-cols-2 gap-6 mb-6">
        <Card>
          <ProfitMarginChart data={analysis.monthlyTrend} />
        </Card>

        <Card>
          <CostBreakdownChart data={analysis.costBreakdown} />
        </Card>
      </div>

      {/* ROI Analysis */}
      <Card>
        <ROIAnalysis analysis={analysis} />
      </Card>
    </div>
  );
}

6. Utilidades y Helpers

6.1 Format Utils

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

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

{
  "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

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

// 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 (
    <BrowserRouter>
      <Suspense fallback={<div>Cargando...</div>}>
        <Routes>
          <Route path="/concepts" element={<ConceptCatalogList />} />
          <Route path="/budgets/:id" element={<BudgetEditor />} />
          <Route path="/cost-control/:budgetId" element={<CostControlDashboard />} />
        </Routes>
      </Suspense>
    </BrowserRouter>
  );
}

11. Deployment

11.1 Build para Producción

# Build
npm run build

# Preview
npm run preview

11.2 Variables de Entorno

# .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