2494 lines
72 KiB
Markdown
2494 lines
72 KiB
Markdown
# ET-COST-001-FRONTEND: Implementación Frontend - Presupuestos y Control de Costos
|
|
|
|
**Épica:** MAI-003 - Presupuestos y Control de Costos
|
|
**Versión:** 1.0
|
|
**Fecha:** 2025-12-06
|
|
**Stack:** React 18 + TypeScript + Vite + Zustand + Chart.js + TanStack Table
|
|
|
|
---
|
|
|
|
## 1. Arquitectura Frontend
|
|
|
|
### 1.1 Estructura de Módulos
|
|
|
|
```
|
|
src/
|
|
├── features/
|
|
│ └── budgets/
|
|
│ ├── concept-catalog/ # Catálogo de conceptos
|
|
│ │ ├── components/
|
|
│ │ │ ├── ConceptCatalogList.tsx
|
|
│ │ │ ├── ConceptFilters.tsx
|
|
│ │ │ ├── ConceptTable.tsx
|
|
│ │ │ ├── CreateConceptModal.tsx
|
|
│ │ │ ├── EditConceptModal.tsx
|
|
│ │ │ ├── BulkUpdatePricesModal.tsx
|
|
│ │ │ └── ConceptDetailsDrawer.tsx
|
|
│ │ ├── hooks/
|
|
│ │ │ ├── useConceptCatalog.ts
|
|
│ │ │ └── useConceptFilters.ts
|
|
│ │ └── stores/
|
|
│ │ └── conceptCatalogStore.ts
|
|
│ │
|
|
│ ├── budgets/ # Gestión de presupuestos
|
|
│ │ ├── components/
|
|
│ │ │ ├── BudgetList.tsx
|
|
│ │ │ ├── BudgetEditor.tsx
|
|
│ │ │ ├── BudgetTree.tsx
|
|
│ │ │ ├── APUEditor.tsx
|
|
│ │ │ ├── ItemBreakdown.tsx
|
|
│ │ │ └── BudgetSummary.tsx
|
|
│ │ ├── hooks/
|
|
│ │ │ ├── useBudget.ts
|
|
│ │ │ └── useBudgetTree.ts
|
|
│ │ └── stores/
|
|
│ │ └── budgetStore.ts
|
|
│ │
|
|
│ ├── cost-control/ # Control de costos
|
|
│ │ ├── components/
|
|
│ │ │ ├── CostControlDashboard.tsx
|
|
│ │ │ ├── CurveSChart.tsx
|
|
│ │ │ ├── VarianceTable.tsx
|
|
│ │ │ ├── ProgressOverview.tsx
|
|
│ │ │ └── AlertsPanel.tsx
|
|
│ │ ├── hooks/
|
|
│ │ │ ├── useCostControl.ts
|
|
│ │ │ └── useCurveS.ts
|
|
│ │ └── stores/
|
|
│ │ └── costControlStore.ts
|
|
│ │
|
|
│ └── profitability/ # Análisis de rentabilidad
|
|
│ ├── components/
|
|
│ │ ├── ProfitabilityDashboard.tsx
|
|
│ │ ├── ProfitMarginChart.tsx
|
|
│ │ ├── ROIAnalysis.tsx
|
|
│ │ └── CostBreakdownChart.tsx
|
|
│ ├── hooks/
|
|
│ │ └── useProfitability.ts
|
|
│ └── stores/
|
|
│ └── profitabilityStore.ts
|
|
│
|
|
├── services/
|
|
│ └── api/
|
|
│ ├── conceptCatalogApi.ts
|
|
│ ├── budgetApi.ts
|
|
│ ├── costControlApi.ts
|
|
│ └── profitabilityApi.ts
|
|
│
|
|
└── types/
|
|
└── budgets.ts
|
|
```
|
|
|
|
---
|
|
|
|
## 2. Types y DTOs
|
|
|
|
### 2.1 Types Core
|
|
|
|
```typescript
|
|
// src/types/budgets.ts
|
|
|
|
export enum ConceptType {
|
|
MATERIAL = 'material',
|
|
LABOR = 'labor',
|
|
EQUIPMENT = 'equipment',
|
|
COMPOSITE = 'composite',
|
|
}
|
|
|
|
export enum ConceptStatus {
|
|
ACTIVE = 'active',
|
|
DEPRECATED = 'deprecated',
|
|
}
|
|
|
|
export interface ConceptCatalog {
|
|
id: string;
|
|
code: string;
|
|
name: string;
|
|
description?: string;
|
|
conceptType: ConceptType;
|
|
category?: string;
|
|
subcategory?: string;
|
|
unit: string;
|
|
|
|
// Pricing
|
|
basePrice?: number;
|
|
unitPrice?: number;
|
|
unitPriceWithVAT?: number;
|
|
includesVAT: boolean;
|
|
currency: string;
|
|
|
|
// Factors
|
|
wasteFactor: number;
|
|
|
|
// Composite concept
|
|
components?: ComponentItem[];
|
|
laborCrew?: LaborCrewItem[];
|
|
indirectPercentage: number;
|
|
financingPercentage: number;
|
|
profitPercentage: number;
|
|
additionalCharges: number;
|
|
directCost?: number;
|
|
|
|
// Metadata
|
|
regionId?: string;
|
|
preferredSupplierId?: string;
|
|
technicalSpecs?: string;
|
|
performance?: string;
|
|
version: number;
|
|
status: ConceptStatus;
|
|
createdAt: string;
|
|
updatedAt: string;
|
|
}
|
|
|
|
export interface ComponentItem {
|
|
conceptId: string;
|
|
quantity: number;
|
|
unit: string;
|
|
name?: string;
|
|
unitPrice?: number;
|
|
}
|
|
|
|
export interface LaborCrewItem {
|
|
category: string;
|
|
quantity: number;
|
|
dailyWage: number;
|
|
fsr: number; // Factor de Salario Real
|
|
}
|
|
|
|
export interface Budget {
|
|
id: string;
|
|
code: string;
|
|
name: string;
|
|
description?: string;
|
|
projectId: string;
|
|
projectName?: string;
|
|
version: number;
|
|
status: 'draft' | 'approved' | 'active' | 'closed';
|
|
|
|
// Amounts
|
|
totalDirectCost: number;
|
|
totalIndirectCost: number;
|
|
totalCost: number;
|
|
totalPrice: number;
|
|
totalPriceWithVAT: number;
|
|
|
|
// Metadata
|
|
validFrom: string;
|
|
validUntil?: string;
|
|
approvedBy?: string;
|
|
approvedAt?: string;
|
|
createdAt: string;
|
|
updatedAt: string;
|
|
}
|
|
|
|
export interface BudgetItem {
|
|
id: string;
|
|
budgetId: string;
|
|
parentId?: string;
|
|
level: number;
|
|
|
|
// Identification
|
|
code: string;
|
|
name: string;
|
|
description?: string;
|
|
itemType: 'chapter' | 'subchapter' | 'concept';
|
|
|
|
// Reference
|
|
conceptId?: string;
|
|
conceptCode?: string;
|
|
|
|
// Quantities
|
|
quantity: number;
|
|
unit: string;
|
|
unitPrice: number;
|
|
totalPrice: number;
|
|
|
|
// Breakdown (APU)
|
|
breakdown?: ItemBreakdown;
|
|
|
|
// Order
|
|
orderIndex: number;
|
|
|
|
// Children
|
|
children?: BudgetItem[];
|
|
}
|
|
|
|
export interface ItemBreakdown {
|
|
materials: BreakdownItem[];
|
|
labor: BreakdownItem[];
|
|
equipment: BreakdownItem[];
|
|
indirectCost: number;
|
|
financingCost: number;
|
|
profitMargin: number;
|
|
additionalCharges: number;
|
|
}
|
|
|
|
export interface BreakdownItem {
|
|
conceptId: string;
|
|
conceptCode: string;
|
|
name: string;
|
|
quantity: number;
|
|
unit: string;
|
|
unitPrice: number;
|
|
totalPrice: number;
|
|
}
|
|
|
|
export interface CostControl {
|
|
id: string;
|
|
budgetId: string;
|
|
periodType: 'weekly' | 'biweekly' | 'monthly';
|
|
|
|
// S-Curve data
|
|
curveData: CurveDataPoint[];
|
|
|
|
// Current status
|
|
budgetedTotal: number;
|
|
spentToDate: number;
|
|
committedAmount: number;
|
|
projectedTotal: number;
|
|
variance: number;
|
|
variancePercentage: number;
|
|
|
|
// Progress
|
|
physicalProgress: number;
|
|
financialProgress: number;
|
|
|
|
// Alerts
|
|
alerts: CostAlert[];
|
|
|
|
updatedAt: string;
|
|
}
|
|
|
|
export interface CurveDataPoint {
|
|
period: string; // ISO date
|
|
periodNumber: number;
|
|
|
|
// Planned (Presupuestado)
|
|
plannedCumulative: number;
|
|
plannedPeriod: number;
|
|
|
|
// Actual (Real)
|
|
actualCumulative: number;
|
|
actualPeriod: number;
|
|
|
|
// Committed (Comprometido)
|
|
committedCumulative: number;
|
|
|
|
// Projected (Proyectado)
|
|
projectedCumulative?: number;
|
|
|
|
// Progress
|
|
physicalProgress: number;
|
|
financialProgress: number;
|
|
|
|
// Variance
|
|
variance: number;
|
|
variancePercentage: number;
|
|
}
|
|
|
|
export interface CostAlert {
|
|
id: string;
|
|
severity: 'info' | 'warning' | 'critical';
|
|
type: 'budget_overrun' | 'schedule_delay' | 'variance_threshold';
|
|
message: string;
|
|
value?: number;
|
|
threshold?: number;
|
|
createdAt: string;
|
|
}
|
|
|
|
export interface ProfitabilityAnalysis {
|
|
budgetId: string;
|
|
projectId: string;
|
|
projectName: string;
|
|
|
|
// Revenue
|
|
totalRevenue: number;
|
|
|
|
// Costs
|
|
directCosts: number;
|
|
indirectCosts: number;
|
|
totalCosts: number;
|
|
|
|
// Profit
|
|
grossProfit: number;
|
|
grossProfitMargin: number; // %
|
|
netProfit: number;
|
|
netProfitMargin: number; // %
|
|
|
|
// ROI
|
|
roi: number; // %
|
|
|
|
// Breakdown by category
|
|
costBreakdown: CostBreakdownItem[];
|
|
|
|
// Trends
|
|
monthlyTrend: MonthlyProfitTrend[];
|
|
|
|
updatedAt: string;
|
|
}
|
|
|
|
export interface CostBreakdownItem {
|
|
category: string;
|
|
amount: number;
|
|
percentage: number;
|
|
color?: string;
|
|
}
|
|
|
|
export interface MonthlyProfitTrend {
|
|
month: string;
|
|
revenue: number;
|
|
costs: number;
|
|
profit: number;
|
|
profitMargin: number;
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## 3. Servicios API
|
|
|
|
### 3.1 Concept Catalog API
|
|
|
|
```typescript
|
|
// src/services/api/conceptCatalogApi.ts
|
|
import { apiClient } from './apiClient';
|
|
import type { ConceptCatalog } from '../../types/budgets';
|
|
|
|
export const conceptCatalogApi = {
|
|
async getAll(filters?: {
|
|
type?: string;
|
|
category?: string;
|
|
status?: string;
|
|
search?: string;
|
|
}): Promise<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
|
|
|
|
```typescript
|
|
// src/services/api/budgetApi.ts
|
|
import { apiClient } from './apiClient';
|
|
import type { Budget, BudgetItem } from '../../types/budgets';
|
|
|
|
export const budgetApi = {
|
|
async getAll(filters?: {
|
|
projectId?: string;
|
|
status?: string;
|
|
}): Promise<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
|
|
|
|
```typescript
|
|
// src/services/api/costControlApi.ts
|
|
import { apiClient } from './apiClient';
|
|
import type { CostControl, CurveDataPoint } from '../../types/budgets';
|
|
|
|
export const costControlApi = {
|
|
async getCostControl(budgetId: string): Promise<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
|
|
|
|
```typescript
|
|
// 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
|
|
|
|
```typescript
|
|
// src/features/budgets/concept-catalog/stores/conceptCatalogStore.ts
|
|
import { create } from 'zustand';
|
|
import { devtools } from 'zustand/middleware';
|
|
import { conceptCatalogApi } from '../../../../services/api/conceptCatalogApi';
|
|
import type { ConceptCatalog } from '../../../../types/budgets';
|
|
|
|
interface ConceptCatalogState {
|
|
concepts: ConceptCatalog[];
|
|
selectedConcept: ConceptCatalog | null;
|
|
loading: boolean;
|
|
error: string | null;
|
|
|
|
// Actions
|
|
fetchConcepts: (filters?: any) => Promise<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
|
|
|
|
```typescript
|
|
// src/features/budgets/budgets/stores/budgetStore.ts
|
|
import { create } from 'zustand';
|
|
import { devtools } from 'zustand/middleware';
|
|
import { budgetApi } from '../../../../services/api/budgetApi';
|
|
import type { Budget, BudgetItem } from '../../../../types/budgets';
|
|
|
|
interface BudgetState {
|
|
budgets: Budget[];
|
|
currentBudget: Budget | null;
|
|
budgetItems: BudgetItem[];
|
|
loading: boolean;
|
|
error: string | null;
|
|
|
|
// Actions
|
|
fetchBudgets: (filters?: any) => Promise<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
|
|
|
|
```typescript
|
|
// src/features/budgets/cost-control/stores/costControlStore.ts
|
|
import { create } from 'zustand';
|
|
import { devtools } from 'zustand/middleware';
|
|
import { costControlApi } from '../../../../services/api/costControlApi';
|
|
import type { CostControl, CurveDataPoint } from '../../../../types/budgets';
|
|
|
|
interface CostControlState {
|
|
costControl: CostControl | null;
|
|
curveData: CurveDataPoint[];
|
|
loading: boolean;
|
|
error: string | null;
|
|
|
|
// Actions
|
|
fetchCostControl: (budgetId: string) => Promise<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
|
|
|
|
```typescript
|
|
// src/features/budgets/profitability/stores/profitabilityStore.ts
|
|
import { create } from 'zustand';
|
|
import { devtools } from 'zustand/middleware';
|
|
import { profitabilityApi } from '../../../../services/api/profitabilityApi';
|
|
import type { ProfitabilityAnalysis } from '../../../../types/budgets';
|
|
|
|
interface ProfitabilityState {
|
|
analysis: ProfitabilityAnalysis | null;
|
|
loading: boolean;
|
|
error: string | null;
|
|
|
|
// Actions
|
|
fetchAnalysis: (budgetId: string) => Promise<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
|
|
|
|
```typescript
|
|
// src/features/budgets/concept-catalog/components/ConceptCatalogList.tsx
|
|
import React, { useEffect, useState } from 'react';
|
|
import { useConceptCatalogStore } from '../stores/conceptCatalogStore';
|
|
import { ConceptTable } from './ConceptTable';
|
|
import { ConceptFilters } from './ConceptFilters';
|
|
import { CreateConceptModal } from './CreateConceptModal';
|
|
import { EditConceptModal } from './EditConceptModal';
|
|
import { BulkUpdatePricesModal } from './BulkUpdatePricesModal';
|
|
import { ConceptDetailsDrawer } from './ConceptDetailsDrawer';
|
|
import { Button } from '../../../../components/ui/Button';
|
|
import { PlusIcon, RefreshIcon } from '../../../../components/icons';
|
|
|
|
export function ConceptCatalogList() {
|
|
const {
|
|
concepts,
|
|
loading,
|
|
error,
|
|
fetchConcepts,
|
|
setSelectedConcept,
|
|
selectedConcept,
|
|
} = useConceptCatalogStore();
|
|
|
|
const [filters, setFilters] = useState({
|
|
type: '',
|
|
category: '',
|
|
status: 'active',
|
|
search: '',
|
|
});
|
|
|
|
const [selectedIds, setSelectedIds] = useState<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)
|
|
|
|
```typescript
|
|
// src/features/budgets/concept-catalog/components/ConceptTable.tsx
|
|
import React, { useMemo } from 'react';
|
|
import {
|
|
useReactTable,
|
|
getCoreRowModel,
|
|
getSortedRowModel,
|
|
getFilteredRowModel,
|
|
getPaginationRowModel,
|
|
flexRender,
|
|
createColumnHelper,
|
|
} from '@tanstack/react-table';
|
|
import type { ConceptCatalog } from '../../../../types/budgets';
|
|
import { formatCurrency } from '../../../../utils/format';
|
|
import { Badge } from '../../../../components/ui/Badge';
|
|
import { Checkbox } from '../../../../components/ui/Checkbox';
|
|
import { IconButton } from '../../../../components/ui/IconButton';
|
|
import { EditIcon, TrashIcon } from '../../../../components/icons';
|
|
|
|
const columnHelper = createColumnHelper<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)
|
|
|
|
```typescript
|
|
// src/features/budgets/budgets/components/APUEditor.tsx
|
|
import React, { useState, useEffect } from 'react';
|
|
import { useConceptCatalogStore } from '../../concept-catalog/stores/conceptCatalogStore';
|
|
import type { ComponentItem, LaborCrewItem } from '../../../../types/budgets';
|
|
import { Button } from '../../../../components/ui/Button';
|
|
import { Input } from '../../../../components/ui/Input';
|
|
import { Select } from '../../../../components/ui/Select';
|
|
import { formatCurrency } from '../../../../utils/format';
|
|
import { PlusIcon, TrashIcon } from '../../../../components/icons';
|
|
|
|
interface APUEditorProps {
|
|
components: ComponentItem[];
|
|
laborCrew: LaborCrewItem[];
|
|
factors: {
|
|
indirectPercentage: number;
|
|
financingPercentage: number;
|
|
profitPercentage: number;
|
|
additionalCharges: number;
|
|
};
|
|
onChange: (data: {
|
|
components: ComponentItem[];
|
|
laborCrew: LaborCrewItem[];
|
|
factors: any;
|
|
}) => void;
|
|
}
|
|
|
|
export function APUEditor({ components, laborCrew, factors, onChange }: APUEditorProps) {
|
|
const { concepts } = useConceptCatalogStore();
|
|
const [localComponents, setLocalComponents] = useState<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
|
|
|
|
```typescript
|
|
// src/features/budgets/budgets/components/BudgetTree.tsx
|
|
import React, { useState } from 'react';
|
|
import type { BudgetItem } from '../../../../types/budgets';
|
|
import { formatCurrency } from '../../../../utils/format';
|
|
import { ChevronRightIcon, ChevronDownIcon, EditIcon } from '../../../../components/icons';
|
|
import { IconButton } from '../../../../components/ui/IconButton';
|
|
|
|
interface BudgetTreeProps {
|
|
items: BudgetItem[];
|
|
onEdit?: (item: BudgetItem) => void;
|
|
onSelect?: (item: BudgetItem) => void;
|
|
}
|
|
|
|
export function BudgetTree({ items, onEdit, onSelect }: BudgetTreeProps) {
|
|
const [expandedIds, setExpandedIds] = useState<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)
|
|
|
|
```typescript
|
|
// src/features/budgets/cost-control/components/CurveSChart.tsx
|
|
import React, { useEffect, useRef } from 'react';
|
|
import {
|
|
Chart as ChartJS,
|
|
CategoryScale,
|
|
LinearScale,
|
|
PointElement,
|
|
LineElement,
|
|
Title,
|
|
Tooltip,
|
|
Legend,
|
|
Filler,
|
|
} from 'chart.js';
|
|
import { Line } from 'react-chartjs-2';
|
|
import type { CurveDataPoint } from '../../../../types/budgets';
|
|
import { formatCurrency } from '../../../../utils/format';
|
|
|
|
ChartJS.register(
|
|
CategoryScale,
|
|
LinearScale,
|
|
PointElement,
|
|
LineElement,
|
|
Title,
|
|
Tooltip,
|
|
Legend,
|
|
Filler
|
|
);
|
|
|
|
interface CurveSChartProps {
|
|
data: CurveDataPoint[];
|
|
}
|
|
|
|
export function CurveSChart({ data }: CurveSChartProps) {
|
|
const chartData = {
|
|
labels: data.map((d) => {
|
|
const date = new Date(d.period);
|
|
return `${date.getMonth() + 1}/${date.getFullYear()}`;
|
|
}),
|
|
datasets: [
|
|
{
|
|
label: 'Presupuestado',
|
|
data: data.map((d) => d.plannedCumulative),
|
|
borderColor: 'rgb(59, 130, 246)',
|
|
backgroundColor: 'rgba(59, 130, 246, 0.1)',
|
|
borderWidth: 2,
|
|
tension: 0.4,
|
|
fill: false,
|
|
},
|
|
{
|
|
label: 'Real',
|
|
data: data.map((d) => d.actualCumulative),
|
|
borderColor: 'rgb(16, 185, 129)',
|
|
backgroundColor: 'rgba(16, 185, 129, 0.1)',
|
|
borderWidth: 2,
|
|
tension: 0.4,
|
|
fill: false,
|
|
},
|
|
{
|
|
label: 'Comprometido',
|
|
data: data.map((d) => d.committedCumulative),
|
|
borderColor: 'rgb(251, 146, 60)',
|
|
backgroundColor: 'rgba(251, 146, 60, 0.1)',
|
|
borderWidth: 2,
|
|
borderDash: [5, 5],
|
|
tension: 0.4,
|
|
fill: false,
|
|
},
|
|
{
|
|
label: 'Proyectado',
|
|
data: data.map((d) => d.projectedCumulative),
|
|
borderColor: 'rgb(168, 85, 247)',
|
|
backgroundColor: 'rgba(168, 85, 247, 0.1)',
|
|
borderWidth: 2,
|
|
borderDash: [10, 5],
|
|
tension: 0.4,
|
|
fill: false,
|
|
},
|
|
],
|
|
};
|
|
|
|
const options = {
|
|
responsive: true,
|
|
maintainAspectRatio: false,
|
|
interaction: {
|
|
mode: 'index' as const,
|
|
intersect: false,
|
|
},
|
|
plugins: {
|
|
legend: {
|
|
position: 'top' as const,
|
|
},
|
|
title: {
|
|
display: true,
|
|
text: 'Curva S - Control de Costos',
|
|
font: {
|
|
size: 16,
|
|
weight: 'bold' as const,
|
|
},
|
|
},
|
|
tooltip: {
|
|
callbacks: {
|
|
label: function (context: any) {
|
|
let label = context.dataset.label || '';
|
|
if (label) {
|
|
label += ': ';
|
|
}
|
|
label += formatCurrency(context.parsed.y);
|
|
return label;
|
|
},
|
|
},
|
|
},
|
|
},
|
|
scales: {
|
|
y: {
|
|
beginAtZero: true,
|
|
ticks: {
|
|
callback: function (value: any) {
|
|
return formatCurrency(value);
|
|
},
|
|
},
|
|
},
|
|
},
|
|
};
|
|
|
|
return (
|
|
<div className="curve-s-chart" style={{ height: '500px' }}>
|
|
<Line data={chartData} options={options} />
|
|
</div>
|
|
);
|
|
}
|
|
```
|
|
|
|
### 5.6 VarianceTable
|
|
|
|
```typescript
|
|
// src/features/budgets/cost-control/components/VarianceTable.tsx
|
|
import React from 'react';
|
|
import type { CurveDataPoint } from '../../../../types/budgets';
|
|
import { formatCurrency, formatPercentage } from '../../../../utils/format';
|
|
|
|
interface VarianceTableProps {
|
|
data: CurveDataPoint[];
|
|
}
|
|
|
|
export function VarianceTable({ data }: VarianceTableProps) {
|
|
return (
|
|
<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
|
|
|
|
```typescript
|
|
// src/features/budgets/profitability/components/ProfitabilityDashboard.tsx
|
|
import React, { useEffect } from 'react';
|
|
import { useProfitabilityStore } from '../stores/profitabilityStore';
|
|
import { ProfitMarginChart } from './ProfitMarginChart';
|
|
import { CostBreakdownChart } from './CostBreakdownChart';
|
|
import { ROIAnalysis } from './ROIAnalysis';
|
|
import { formatCurrency, formatPercentage } from '../../../../utils/format';
|
|
import { Card } from '../../../../components/ui/Card';
|
|
|
|
interface ProfitabilityDashboardProps {
|
|
budgetId: string;
|
|
}
|
|
|
|
export function ProfitabilityDashboard({ budgetId }: ProfitabilityDashboardProps) {
|
|
const { analysis, loading, fetchAnalysis } = useProfitabilityStore();
|
|
|
|
useEffect(() => {
|
|
fetchAnalysis(budgetId);
|
|
}, [budgetId, fetchAnalysis]);
|
|
|
|
if (loading) {
|
|
return <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
|
|
|
|
```typescript
|
|
// src/utils/format.ts
|
|
|
|
export function formatCurrency(value: number, currency: string = 'MXN'): string {
|
|
return new Intl.NumberFormat('es-MX', {
|
|
style: 'currency',
|
|
currency,
|
|
minimumFractionDigits: 2,
|
|
maximumFractionDigits: 2,
|
|
}).format(value);
|
|
}
|
|
|
|
export function formatPercentage(value: number, decimals: number = 2): string {
|
|
return `${value.toFixed(decimals)}%`;
|
|
}
|
|
|
|
export function formatNumber(value: number, decimals: number = 2): string {
|
|
return new Intl.NumberFormat('es-MX', {
|
|
minimumFractionDigits: decimals,
|
|
maximumFractionDigits: decimals,
|
|
}).format(value);
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## 7. Configuración Vite
|
|
|
|
```typescript
|
|
// vite.config.ts
|
|
import { defineConfig } from 'vite';
|
|
import react from '@vitejs/plugin-react';
|
|
import path from 'path';
|
|
|
|
export default defineConfig({
|
|
plugins: [react()],
|
|
resolve: {
|
|
alias: {
|
|
'@': path.resolve(__dirname, './src'),
|
|
'@features': path.resolve(__dirname, './src/features'),
|
|
'@components': path.resolve(__dirname, './src/components'),
|
|
'@services': path.resolve(__dirname, './src/services'),
|
|
'@types': path.resolve(__dirname, './src/types'),
|
|
'@utils': path.resolve(__dirname, './src/utils'),
|
|
},
|
|
},
|
|
server: {
|
|
port: 3000,
|
|
proxy: {
|
|
'/api': {
|
|
target: 'http://localhost:4000',
|
|
changeOrigin: true,
|
|
},
|
|
},
|
|
},
|
|
build: {
|
|
sourcemap: true,
|
|
rollupOptions: {
|
|
output: {
|
|
manualChunks: {
|
|
'react-vendor': ['react', 'react-dom', 'react-router-dom'],
|
|
'chart-vendor': ['chart.js', 'react-chartjs-2'],
|
|
'table-vendor': ['@tanstack/react-table'],
|
|
'zustand-vendor': ['zustand'],
|
|
},
|
|
},
|
|
},
|
|
},
|
|
});
|
|
```
|
|
|
|
---
|
|
|
|
## 8. Package.json
|
|
|
|
```json
|
|
{
|
|
"name": "erp-construccion-frontend",
|
|
"version": "1.0.0",
|
|
"type": "module",
|
|
"scripts": {
|
|
"dev": "vite",
|
|
"build": "tsc && vite build",
|
|
"preview": "vite preview",
|
|
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
|
|
"test": "vitest"
|
|
},
|
|
"dependencies": {
|
|
"react": "^18.3.1",
|
|
"react-dom": "^18.3.1",
|
|
"react-router-dom": "^6.22.0",
|
|
"zustand": "^4.5.0",
|
|
"@tanstack/react-table": "^8.11.0",
|
|
"chart.js": "^4.4.1",
|
|
"react-chartjs-2": "^5.2.0",
|
|
"axios": "^1.6.5",
|
|
"clsx": "^2.1.0",
|
|
"date-fns": "^3.3.0"
|
|
},
|
|
"devDependencies": {
|
|
"@types/react": "^18.2.55",
|
|
"@types/react-dom": "^18.2.19",
|
|
"@typescript-eslint/eslint-plugin": "^6.21.0",
|
|
"@typescript-eslint/parser": "^6.21.0",
|
|
"@vitejs/plugin-react": "^4.2.1",
|
|
"autoprefixer": "^10.4.17",
|
|
"eslint": "^8.56.0",
|
|
"eslint-plugin-react-hooks": "^4.6.0",
|
|
"eslint-plugin-react-refresh": "^0.4.5",
|
|
"postcss": "^8.4.35",
|
|
"tailwindcss": "^3.4.1",
|
|
"typescript": "^5.3.3",
|
|
"vite": "^5.1.0",
|
|
"vitest": "^1.2.2"
|
|
}
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## 9. Testing
|
|
|
|
### 9.1 Store Tests
|
|
|
|
```typescript
|
|
// src/features/budgets/concept-catalog/stores/conceptCatalogStore.test.ts
|
|
import { renderHook, act, waitFor } from '@testing-library/react';
|
|
import { useConceptCatalogStore } from './conceptCatalogStore';
|
|
import { conceptCatalogApi } from '../../../../services/api/conceptCatalogApi';
|
|
|
|
jest.mock('../../../../services/api/conceptCatalogApi');
|
|
|
|
describe('ConceptCatalogStore', () => {
|
|
beforeEach(() => {
|
|
useConceptCatalogStore.setState({
|
|
concepts: [],
|
|
loading: false,
|
|
error: null,
|
|
});
|
|
});
|
|
|
|
it('debe cargar conceptos exitosamente', async () => {
|
|
const mockConcepts = [
|
|
{
|
|
id: '1',
|
|
code: 'MAT-2025-001',
|
|
name: 'Cemento CPC 30R',
|
|
conceptType: 'material',
|
|
unit: 'ton',
|
|
basePrice: 4300,
|
|
},
|
|
];
|
|
|
|
(conceptCatalogApi.getAll as jest.Mock).mockResolvedValue(mockConcepts);
|
|
|
|
const { result } = renderHook(() => useConceptCatalogStore());
|
|
|
|
await act(async () => {
|
|
await result.current.fetchConcepts();
|
|
});
|
|
|
|
await waitFor(() => {
|
|
expect(result.current.concepts).toEqual(mockConcepts);
|
|
expect(result.current.loading).toBe(false);
|
|
});
|
|
});
|
|
|
|
it('debe crear un concepto nuevo', async () => {
|
|
const newConcept = {
|
|
name: 'Acero fy=4200',
|
|
conceptType: 'material',
|
|
unit: 'kg',
|
|
basePrice: 18.50,
|
|
};
|
|
|
|
const createdConcept = { ...newConcept, id: '2', code: 'MAT-2025-002' };
|
|
|
|
(conceptCatalogApi.create as jest.Mock).mockResolvedValue(createdConcept);
|
|
|
|
const { result } = renderHook(() => useConceptCatalogStore());
|
|
|
|
await act(async () => {
|
|
await result.current.createConcept(newConcept);
|
|
});
|
|
|
|
await waitFor(() => {
|
|
expect(result.current.concepts).toContainEqual(createdConcept);
|
|
});
|
|
});
|
|
});
|
|
```
|
|
|
|
---
|
|
|
|
## 10. Performance y Optimizaciones
|
|
|
|
### 10.1 Estrategias de Optimización
|
|
|
|
1. **Code Splitting**: Dividir el bundle por feature modules
|
|
2. **Lazy Loading**: Cargar componentes bajo demanda
|
|
3. **Memoization**: Usar React.memo, useMemo, useCallback
|
|
4. **Virtual Scrolling**: Para tablas grandes (react-window)
|
|
5. **Debounce**: En búsquedas y filtros
|
|
6. **Caching**: Con React Query o SWR para datos API
|
|
|
|
### 10.2 Lazy Loading Example
|
|
|
|
```typescript
|
|
// src/App.tsx
|
|
import { lazy, Suspense } from 'react';
|
|
import { BrowserRouter, Routes, Route } from 'react-router-dom';
|
|
|
|
const ConceptCatalogList = lazy(() => import('./features/budgets/concept-catalog/components/ConceptCatalogList'));
|
|
const BudgetEditor = lazy(() => import('./features/budgets/budgets/components/BudgetEditor'));
|
|
const CostControlDashboard = lazy(() => import('./features/budgets/cost-control/components/CostControlDashboard'));
|
|
|
|
function App() {
|
|
return (
|
|
<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
|
|
|
|
```bash
|
|
# Build
|
|
npm run build
|
|
|
|
# Preview
|
|
npm run preview
|
|
```
|
|
|
|
### 11.2 Variables de Entorno
|
|
|
|
```env
|
|
# .env.production
|
|
VITE_API_URL=https://api.erp-construccion.com
|
|
VITE_APP_VERSION=1.0.0
|
|
VITE_ENVIRONMENT=production
|
|
```
|
|
|
|
---
|
|
|
|
**Estado:** Ready for Implementation
|
|
**Última actualización:** 2025-12-06
|