72 KiB
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
- Code Splitting: Dividir el bundle por feature modules
- Lazy Loading: Cargar componentes bajo demanda
- Memoization: Usar React.memo, useMemo, useCallback
- Virtual Scrolling: Para tablas grandes (react-window)
- Debounce: En búsquedas y filtros
- 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