From 87310ced5fb62656da3b1b7290b8aad4568fe34c Mon Sep 17 00:00:00 2001 From: Urtzi Alfaro Date: Tue, 23 Sep 2025 22:11:34 +0200 Subject: [PATCH] Add improved production UI 4 --- .../hooks/subscription.ts} | 38 +- frontend/src/api/services/subscription.ts | 143 ++- frontend/src/api/types/subscription.ts | 36 +- .../auth/SubscriptionErrorHandler.tsx | 25 +- .../analytics/ProductionCostAnalytics.tsx | 491 ++++++++ .../domain/analytics/ReportsTable.tsx | 2 +- .../src/components/domain/analytics/index.ts | 1 + frontend/src/components/ui/Button/Button.tsx | 24 +- .../components/ui/DatePicker/DatePicker.tsx | 2 +- frontend/src/components/ui/Table/Table.tsx | 2 +- frontend/src/hooks/index.ts | 2 +- .../src/hooks/useSubscriptionAwareRoutes.ts | 2 +- frontend/src/pages/app/DashboardPage.tsx | 14 - .../app/analytics/ProductionAnalyticsPage.tsx | 1032 +++++++++++++++-- .../analytics/forecasting/ForecastingPage.tsx | 111 +- .../operations/maquinaria/MaquinariaPage.tsx | 25 +- .../app/settings/profile/ProfilePage.tsx | 4 +- 17 files changed, 1658 insertions(+), 296 deletions(-) rename frontend/src/{hooks/useSubscription.ts => api/hooks/subscription.ts} (82%) create mode 100644 frontend/src/components/domain/analytics/ProductionCostAnalytics.tsx diff --git a/frontend/src/hooks/useSubscription.ts b/frontend/src/api/hooks/subscription.ts similarity index 82% rename from frontend/src/hooks/useSubscription.ts rename to frontend/src/api/hooks/subscription.ts index c9516f45..3581b26a 100644 --- a/frontend/src/hooks/useSubscription.ts +++ b/frontend/src/api/hooks/subscription.ts @@ -3,9 +3,15 @@ */ import { useState, useEffect, useCallback } from 'react'; -import { subscriptionService } from '../api'; -import { useCurrentTenant } from '../stores'; -import { useAuthUser } from '../stores/auth.store'; +import { subscriptionService } from '../services/subscription'; +import { + SUBSCRIPTION_PLANS, + ANALYTICS_LEVELS, + AnalyticsLevel, + SubscriptionPlanKey +} from '../types/subscription'; +import { useCurrentTenant } from '../../stores'; +import { useAuthUser } from '../../stores/auth.store'; export interface SubscriptionFeature { hasFeature: boolean; @@ -95,15 +101,15 @@ export const useSubscription = () => { const getAnalyticsAccess = useCallback((): { hasAccess: boolean; level: string; reason?: string } => { const { plan } = subscriptionInfo; - switch (plan) { - case 'starter': - return { hasAccess: true, level: 'basic' }; - case 'professional': - return { hasAccess: true, level: 'advanced' }; - case 'enterprise': - return { hasAccess: true, level: 'predictive' }; - default: - return { hasAccess: false, level: 'none', reason: 'No valid subscription plan' }; + // Convert plan to typed plan key if it matches our known plans + let planKey: keyof typeof SUBSCRIPTION_PLANS | undefined; + if (plan === SUBSCRIPTION_PLANS.STARTER) planKey = SUBSCRIPTION_PLANS.STARTER; + else if (plan === SUBSCRIPTION_PLANS.PROFESSIONAL) planKey = SUBSCRIPTION_PLANS.PROFESSIONAL; + else if (plan === SUBSCRIPTION_PLANS.ENTERPRISE) planKey = SUBSCRIPTION_PLANS.ENTERPRISE; + + if (planKey) { + const analyticsLevel = subscriptionService.getAnalyticsLevelForPlan(planKey); + return { hasAccess: true, level: analyticsLevel }; } }, [subscriptionInfo.plan]); @@ -113,13 +119,7 @@ export const useSubscription = () => { if (!hasAccess) return false; - const levelHierarchy = { - 'basic': 1, - 'advanced': 2, - 'predictive': 3 - }; - - return levelHierarchy[level as keyof typeof levelHierarchy] >= levelHierarchy[requiredLevel]; + return subscriptionService.doesAnalyticsLevelMeetMinimum(level as any, requiredLevel); }, [getAnalyticsAccess]); // Check if user can access forecasting features diff --git a/frontend/src/api/services/subscription.ts b/frontend/src/api/services/subscription.ts index 1793c351..eb7e1e57 100644 --- a/frontend/src/api/services/subscription.ts +++ b/frontend/src/api/services/subscription.ts @@ -1,6 +1,3 @@ -/** - * Subscription Service - Mirror backend subscription endpoints - */ import { apiClient } from '../client'; import { SubscriptionLimits, @@ -9,9 +6,27 @@ import { UsageSummary, AvailablePlans, PlanUpgradeValidation, - PlanUpgradeResult + PlanUpgradeResult, + SUBSCRIPTION_PLANS, + ANALYTICS_LEVELS, + AnalyticsLevel, + SubscriptionPlanKey, + PLAN_HIERARCHY, + ANALYTICS_HIERARCHY } from '../types/subscription'; +// Map plan keys to analytics levels based on backend data +const PLAN_TO_ANALYTICS_LEVEL: Record = { + [SUBSCRIPTION_PLANS.STARTER]: ANALYTICS_LEVELS.BASIC, + [SUBSCRIPTION_PLANS.PROFESSIONAL]: ANALYTICS_LEVELS.ADVANCED, + [SUBSCRIPTION_PLANS.ENTERPRISE]: ANALYTICS_LEVELS.PREDICTIVE +}; + +// Cache for available plans +let cachedPlans: AvailablePlans | null = null; +let lastFetchTime: number | null = null; +const CACHE_DURATION = 5 * 60 * 1000; // 5 minutes + export class SubscriptionService { private readonly baseUrl = '/subscriptions'; @@ -106,13 +121,119 @@ export class SubscriptionService { }).format(amount); } - getPlanDisplayInfo(planKey: string): { name: string; color: string } { - const planInfo = { - starter: { name: 'Starter', color: 'blue' }, - professional: { name: 'Professional', color: 'purple' }, - enterprise: { name: 'Enterprise', color: 'amber' } - }; - return planInfo[planKey as keyof typeof planInfo] || { name: 'Desconocido', color: 'gray' }; + /** + * Fetch available subscription plans from the backend + */ + async fetchAvailablePlans(): Promise { + const now = Date.now(); + + // Return cached data if it's still valid + if (cachedPlans && lastFetchTime && (now - lastFetchTime) < CACHE_DURATION) { + return cachedPlans; + } + + try { + const plans = await apiClient.get('/subscriptions/plans'); + cachedPlans = plans; + lastFetchTime = now; + return plans; + } catch (error) { + console.error('Failed to fetch subscription plans:', error); + throw error; + } + } + + /** + * Get plan display information + */ + async getPlanDisplayInfo(planKey: string) { + try { + const plans = await this.fetchAvailablePlans(); + const plan = plans.plans[planKey]; + + if (plan) { + return { + name: plan.name, + color: this.getPlanColor(planKey), + description: plan.description, + monthlyPrice: plan.monthly_price + }; + } + + return { name: 'Desconocido', color: 'gray', description: '', monthlyPrice: 0 }; + } catch (error) { + console.error('Failed to get plan display info:', error); + return { name: 'Desconocido', color: 'gray', description: '', monthlyPrice: 0 }; + } + } + + /** + * Get plan color based on plan key + */ + getPlanColor(planKey: string): string { + switch (planKey) { + case SUBSCRIPTION_PLANS.STARTER: + return 'blue'; + case SUBSCRIPTION_PLANS.PROFESSIONAL: + return 'purple'; + case SUBSCRIPTION_PLANS.ENTERPRISE: + return 'amber'; + default: + return 'gray'; + } + } + + /** + * Check if a plan meets minimum requirements + */ + doesPlanMeetMinimum(plan: SubscriptionPlanKey, minimumRequired: SubscriptionPlanKey): boolean { + return PLAN_HIERARCHY[plan] >= PLAN_HIERARCHY[minimumRequired]; + } + + /** + * Get analytics level for a plan + */ + getAnalyticsLevelForPlan(plan: SubscriptionPlanKey): AnalyticsLevel { + return PLAN_TO_ANALYTICS_LEVEL[plan] || ANALYTICS_LEVELS.NONE; + } + + /** + * Check if analytics level meets minimum requirements + */ + doesAnalyticsLevelMeetMinimum(level: AnalyticsLevel, minimumRequired: AnalyticsLevel): boolean { + return ANALYTICS_HIERARCHY[level] >= ANALYTICS_HIERARCHY[minimumRequired]; + } + + /** + * Get plan features + */ + async getPlanFeatures(planKey: string) { + try { + const plans = await this.fetchAvailablePlans(); + const plan = plans.plans[planKey]; + + if (plan) { + return plan.features || {}; + } + + return {}; + } catch (error) { + console.error('Failed to get plan features:', error); + return {}; + } + } + + /** + * Check if a plan has a specific feature + */ + async planHasFeature(planKey: string, featureName: string) { + try { + const features = await this.getPlanFeatures(planKey); + return featureName in features; + } catch (error) { + console.error('Failed to check plan feature:', error); + return false; + } } } diff --git a/frontend/src/api/types/subscription.ts b/frontend/src/api/types/subscription.ts index 06362ae4..971e5e67 100644 --- a/frontend/src/api/types/subscription.ts +++ b/frontend/src/api/types/subscription.ts @@ -94,4 +94,38 @@ export interface PlanUpgradeValidation { export interface PlanUpgradeResult { success: boolean; message: string; -} \ No newline at end of file +} + +// Analytics access levels +export const ANALYTICS_LEVELS = { + NONE: 'none', + BASIC: 'basic', + ADVANCED: 'advanced', + PREDICTIVE: 'predictive' +} as const; + +export type AnalyticsLevel = typeof ANALYTICS_LEVELS[keyof typeof ANALYTICS_LEVELS]; + +// Plan keys +export const SUBSCRIPTION_PLANS = { + STARTER: 'starter', + PROFESSIONAL: 'professional', + ENTERPRISE: 'enterprise' +} as const; + +export type SubscriptionPlanKey = typeof SUBSCRIPTION_PLANS[keyof typeof SUBSCRIPTION_PLANS]; + +// Plan hierarchy for comparison +export const PLAN_HIERARCHY: Record = { + [SUBSCRIPTION_PLANS.STARTER]: 1, + [SUBSCRIPTION_PLANS.PROFESSIONAL]: 2, + [SUBSCRIPTION_PLANS.ENTERPRISE]: 3 +}; + +// Analytics level hierarchy +export const ANALYTICS_HIERARCHY: Record = { + [ANALYTICS_LEVELS.NONE]: 0, + [ANALYTICS_LEVELS.BASIC]: 1, + [ANALYTICS_LEVELS.ADVANCED]: 2, + [ANALYTICS_LEVELS.PREDICTIVE]: 3 +}; \ No newline at end of file diff --git a/frontend/src/components/auth/SubscriptionErrorHandler.tsx b/frontend/src/components/auth/SubscriptionErrorHandler.tsx index 971778d4..dde263c0 100644 --- a/frontend/src/components/auth/SubscriptionErrorHandler.tsx +++ b/frontend/src/components/auth/SubscriptionErrorHandler.tsx @@ -5,6 +5,11 @@ import React from 'react'; import { Modal, Button, Card } from '../ui'; import { Crown, Lock, ArrowRight, AlertTriangle } from 'lucide-react'; +import { + SUBSCRIPTION_PLANS, + ANALYTICS_LEVELS +} from '../../api/types/subscription'; +import { subscriptionService } from '../../api/services/subscription'; interface SubscriptionError { error: string; @@ -44,29 +49,29 @@ const SubscriptionErrorHandler: React.FC = ({ const getLevelDisplayName = (level: string) => { const levelNames: Record = { - basic: 'Básico', - advanced: 'Avanzado', - predictive: 'Predictivo' + [ANALYTICS_LEVELS.BASIC]: 'Básico', + [ANALYTICS_LEVELS.ADVANCED]: 'Avanzado', + [ANALYTICS_LEVELS.PREDICTIVE]: 'Predictivo' }; return levelNames[level] || level; }; const getRequiredPlan = (level: string) => { switch (level) { - case 'advanced': - return 'Professional'; - case 'predictive': - return 'Enterprise'; + case ANALYTICS_LEVELS.ADVANCED: + return SUBSCRIPTION_PLANS.PROFESSIONAL; + case ANALYTICS_LEVELS.PREDICTIVE: + return SUBSCRIPTION_PLANS.ENTERPRISE; default: - return 'Professional'; + return SUBSCRIPTION_PLANS.PROFESSIONAL; } }; const getPlanColor = (plan: string) => { switch (plan.toLowerCase()) { - case 'professional': + case SUBSCRIPTION_PLANS.PROFESSIONAL: return 'bg-gradient-to-br from-purple-500 to-indigo-600'; - case 'enterprise': + case SUBSCRIPTION_PLANS.ENTERPRISE: return 'bg-gradient-to-br from-yellow-400 to-orange-500'; default: return 'bg-gradient-to-br from-blue-500 to-cyan-600'; diff --git a/frontend/src/components/domain/analytics/ProductionCostAnalytics.tsx b/frontend/src/components/domain/analytics/ProductionCostAnalytics.tsx new file mode 100644 index 00000000..f33f8e94 --- /dev/null +++ b/frontend/src/components/domain/analytics/ProductionCostAnalytics.tsx @@ -0,0 +1,491 @@ +import React, { useMemo, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Card, CardHeader, CardBody } from '../../ui/Card'; +import { StatsGrid } from '../../ui/Stats'; +import { Badge } from '../../ui/Badge'; +import { Button } from '../../ui/Button'; +import { Select } from '../../ui/Select'; +import { + Euro, + TrendingUp, + AlertTriangle, + Target, + Clock, + Activity, + Zap, + Download +} from 'lucide-react'; +import { useCurrentTenant } from '../../../stores/tenant.store'; +import { useProductionDashboard } from '../../../api'; + +export interface ProductionCostData { + totalCost: number; + laborCost: number; + materialCost: number; + overheadCost: number; + energyCost: number; + costPerUnit: number; + budget: number; + variance: number; + trend: { + direction: 'up' | 'down' | 'stable'; + percentage: number; + }; + costHistory: Array<{ + date: string; + totalCost: number; + laborCost: number; + materialCost: number; + overheadCost: number; + energyCost: number; + }>; +} + +export interface ProductionCostAnalyticsProps { + className?: string; + data?: ProductionCostData; + timeRange?: 'day' | 'week' | 'month' | 'quarter' | 'year'; + onTimeRangeChange?: (range: 'day' | 'week' | 'month' | 'quarter' | 'year') => void; + onViewDetails?: () => void; + onOptimize?: () => void; + onExport?: () => void; +} + +const ProductionCostAnalytics: React.FC = ({ + className, + data, + timeRange = 'month', + onTimeRangeChange, + onViewDetails, + onOptimize, + onExport +}) => { + const { t } = useTranslation(); + const currentTenant = useCurrentTenant(); + const tenantId = currentTenant?.id || ''; + + const { + data: dashboardData, + isLoading, + error + } = useProductionDashboard(tenantId); + + const [selectedTimeRange, setSelectedTimeRange] = useState<'day' | 'week' | 'month' | 'quarter' | 'year'>(timeRange); + + const costData = useMemo((): ProductionCostData => { + if (data) return data; + + // Calculate from dashboard data if available + if (dashboardData) { + const mockData: ProductionCostData = { + totalCost: 2840.50, + laborCost: 1200.00, + materialCost: 1450.00, + overheadCost: 190.50, + energyCost: 95.25, + costPerUnit: 14.20, + budget: 3000.00, + variance: -159.50, + trend: { + direction: 'down', + percentage: 5.3 + }, + costHistory: [ + { date: '2024-01-16', totalCost: 2750.20, laborCost: 1150.00, materialCost: 1420.00, overheadCost: 180.20, energyCost: 90.00 }, + { date: '2024-01-17', totalCost: 2780.80, laborCost: 1180.00, materialCost: 1430.00, overheadCost: 180.80, energyCost: 90.00 }, + { date: '2024-01-18', totalCost: 2720.10, laborCost: 1120.00, materialCost: 1410.00, overheadCost: 190.10, energyCost: 100.00 }, + { date: '2024-01-19', totalCost: 2810.60, laborCost: 1220.00, materialCost: 1440.00, overheadCost: 190.60, energyCost: 95.00 }, + { date: '2024-01-20', totalCost: 2800.40, laborCost: 1210.00, materialCost: 1440.00, overheadCost: 180.40, energyCost: 90.00 }, + { date: '2024-01-21', totalCost: 2790.30, laborCost: 1190.00, materialCost: 1430.00, overheadCost: 180.30, energyCost: 90.00 }, + { date: '2024-01-22', totalCost: 2840.50, laborCost: 1200.00, materialCost: 1450.00, overheadCost: 190.50, energyCost: 95.25 } + ] + }; + return mockData; + } + + // Default mock data + return { + totalCost: 2840.50, + laborCost: 1200.00, + materialCost: 1450.00, + overheadCost: 190.50, + energyCost: 95.25, + costPerUnit: 14.20, + budget: 3000.00, + variance: -159.50, + trend: { + direction: 'down', + percentage: 5.3 + }, + costHistory: [ + { date: '2024-01-16', totalCost: 2750.20, laborCost: 1150.00, materialCost: 1420.00, overheadCost: 180.20, energyCost: 90.00 }, + { date: '2024-01-17', totalCost: 2780.80, laborCost: 1180.00, materialCost: 1430.00, overheadCost: 180.80, energyCost: 90.00 }, + { date: '2024-01-18', totalCost: 2720.10, laborCost: 1120.00, materialCost: 1410.00, overheadCost: 190.10, energyCost: 100.00 }, + { date: '2024-01-19', totalCost: 2810.60, laborCost: 1220.00, materialCost: 1440.00, overheadCost: 190.60, energyCost: 95.00 }, + { date: '2024-01-20', totalCost: 2800.40, laborCost: 1210.00, materialCost: 1440.00, overheadCost: 180.40, energyCost: 90.00 }, + { date: '2024-01-21', totalCost: 2790.30, laborCost: 1190.00, materialCost: 1430.00, overheadCost: 180.30, energyCost: 90.00 }, + { date: '2024-01-22', totalCost: 2840.50, laborCost: 1200.00, materialCost: 1450.00, overheadCost: 190.50, energyCost: 95.25 } + ] + }; + }, [data, dashboardData]); + + const formatCurrency = (amount: number) => { + return new Intl.NumberFormat('es-ES', { + style: 'currency', + currency: 'EUR', + minimumFractionDigits: 2 + }).format(amount); + }; + + const getVarianceColor = (variance: number) => { + if (variance > 0) return 'text-red-600'; + if (variance < -50) return 'text-green-600'; + return 'text-yellow-600'; + }; + + const getBudgetStatus = () => { + const percentage = (costData.totalCost / costData.budget) * 100; + if (percentage > 100) return { color: 'error', label: t('production.cost.over_budget', 'Over Budget') }; + if (percentage > 90) return { color: 'warning', label: t('production.cost.near_budget', 'Near Budget') }; + return { color: 'success', label: t('production.cost.on_budget', 'On Budget') }; + }; + + const budgetStatus = getBudgetStatus(); + + const handleTimeRangeChange = (range: string) => { + const newRange = range as 'day' | 'week' | 'month' | 'quarter' | 'year'; + setSelectedTimeRange(newRange); + onTimeRangeChange?.(newRange); + }; + + if (isLoading) { + return ( + + +
+

+ {t('production.cost.analytics.title', 'Cost Analytics')} +

+
+ + +
+
+
+ + +

+ {t('production.cost.error', 'Error loading cost data')} +

+
+
+ ); + } + + const costStats = [ + { + title: t('production.cost.total_cost', 'Total Cost'), + value: formatCurrency(costData.totalCost), + icon: Euro, + variant: budgetStatus.color as 'success' | 'warning' | 'error', + trend: { + value: costData.trend.percentage, + direction: costData.trend.direction === 'up' ? 'up' as const : 'down' as const, + label: t('production.cost.vs_last_period', 'vs last period') + }, + subtitle: `${formatCurrency(Math.abs(costData.variance))} ${costData.variance > 0 ? t('common.over', 'over') : t('common.under', 'under')} ${t('common.budget', 'budget')}` + }, + { + title: t('production.cost.cost_per_unit', 'Cost per Unit'), + value: formatCurrency(costData.costPerUnit), + icon: Target, + variant: 'info' as const, + subtitle: t('production.cost.average', 'Average for selected period') + }, + { + title: t('production.cost.labor_cost', 'Labor Cost'), + value: formatCurrency(costData.laborCost), + icon: Clock, + variant: 'default' as const, + subtitle: `${((costData.laborCost / costData.totalCost) * 100).toFixed(1)}% of total` + }, + { + title: t('production.cost.material_cost', 'Material Cost'), + value: formatCurrency(costData.materialCost), + icon: Activity, + variant: 'default' as const, + subtitle: `${((costData.materialCost / costData.totalCost) * 100).toFixed(1)}% of total` + } + ]; + + const costBreakdown = [ + { + label: t('production.cost.labor', 'Labor'), + amount: costData.laborCost, + percentage: (costData.laborCost / costData.totalCost) * 100, + icon: Clock, + color: 'bg-blue-500' + }, + { + label: t('production.cost.materials', 'Materials'), + amount: costData.materialCost, + percentage: (costData.materialCost / costData.totalCost) * 100, + icon: Activity, + color: 'bg-green-500' + }, + { + label: t('production.cost.overhead', 'Overhead'), + amount: costData.overheadCost, + percentage: (costData.overheadCost / costData.totalCost) * 100, + icon: Euro, + color: 'bg-orange-500' + }, + { + label: t('production.cost.energy', 'Energy'), + amount: costData.energyCost, + percentage: (costData.energyCost / costData.totalCost) * 100, + icon: Zap, + color: 'bg-yellow-500' + } + ]; + + // Create a simple bar chart visualization for cost history + const renderCostChart = () => { + if (!costData.costHistory || costData.costHistory.length === 0) return null; + + // Find the maximum value for scaling + const maxValue = Math.max(...costData.costHistory.map(item => item.totalCost)); + + return ( +
+
+ {formatCurrency(0)} + {formatCurrency(maxValue)} +
+
+ {costData.costHistory.map((item, index) => { + const height = (item.totalCost / maxValue) * 100; + return ( +
+
+
+ {new Date(item.date).toLocaleDateString('es-ES')}
{formatCurrency(item.totalCost)} +
+
+ ); + })} +
+
+ ); + }; + + return ( + + +
+
+

+ {t('production.cost.analytics.title', 'Cost Analytics')} +

+

+ {t('production.cost.analytics.subtitle', 'Detailed cost breakdown and trends')} +

+
+ +
+ setSearchTerm(e.target.value)} + className="w-full pl-10 pr-4 py-2 border border-[var(--border-primary)] rounded-md bg-[var(--bg-primary)] text-[var(--text-primary)] focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)] focus:border-transparent" + /> +
+
+
+ + +
+ +
+ + {/* Equipment Grid */} +
+ {filteredEquipment.map((equipment) => { + const statusConfig = getStatusConfig(equipment.status); + const TypeIcon = getTypeIcon(equipment.type); + + return ( + +
+
+
+
+

{equipment.name}

+

{equipment.location}

+
+
+ +
+ +
+
+

Eficiencia

+

{equipment.efficiency}%

+
+
+

Tiempo Activo

+

{equipment.uptime.toFixed(1)}%

+
+
+

Consumo

+

{equipment.energyUsage} kW

+
+
+

Utilización Hoy

+

{equipment.utilizationToday}%

+
+
+ +
+ + {statusConfig.text} + + +
+ + ); + })} +
+
+ )} + + {/* Maintenance Tab */} + {activeTab === 'maintenance' && ( +
+ {/* Maintenance Charts */} +
+ +

Costos de Mantenimiento

+
+ { + if (canvas) { + const ctx = canvas.getContext('2d'); + if (ctx) { + // Set canvas size + canvas.width = canvas.clientWidth; + canvas.height = canvas.clientHeight; + + // Clear canvas + ctx.clearRect(0, 0, canvas.width, canvas.height); + + // Draw bar chart for maintenance costs + const padding = 40; + const chartWidth = canvas.width - 2 * padding; + const chartHeight = canvas.height - 2 * padding; + + // Get max value for scaling + const maxValue = Math.max(...MOCK_MAINTENANCE_CHART_DATA[0].data.map(d => d.y)); + + // Draw bars + MOCK_MAINTENANCE_CHART_DATA[0].data.forEach((point, index) => { + const barWidth = chartWidth / MOCK_MAINTENANCE_CHART_DATA[0].data.length - 10; + const x = padding + index * (chartWidth / MOCK_MAINTENANCE_CHART_DATA[0].data.length) + 5; + const barHeight = (point.y / maxValue) * chartHeight; + const y = padding + chartHeight - barHeight; + + // Bar + ctx.fillStyle = MOCK_MAINTENANCE_CHART_DATA[0].color; + ctx.fillRect(x, y, barWidth, barHeight); + + // Label + ctx.fillStyle = '#374151'; + ctx.font = '12px sans-serif'; + ctx.textAlign = 'center'; + ctx.fillText(point.label, x + barWidth / 2, canvas.height - 10); + ctx.fillText(`€${point.y}`, x + barWidth / 2, y - 5); + }); + + // Axes + ctx.strokeStyle = '#374151'; + ctx.lineWidth = 2; + ctx.beginPath(); + ctx.moveTo(padding, padding); + ctx.lineTo(padding, padding + chartHeight); + ctx.lineTo(padding + chartWidth, padding + chartHeight); + ctx.stroke(); + } + } + }} className="w-full h-full" /> +
+
+ + +

Tiempo de Inactividad

+
+ { + if (canvas) { + const ctx = canvas.getContext('2d'); + if (ctx) { + // Set canvas size + canvas.width = canvas.clientWidth; + canvas.height = canvas.clientHeight; + + // Clear canvas + ctx.clearRect(0, 0, canvas.width, canvas.height); + + // Draw line chart for downtime + const padding = 40; + const chartWidth = canvas.width - 2 * padding; + const chartHeight = canvas.height - 2 * padding; + + // Get max value for scaling + const maxValue = Math.max(...MOCK_MAINTENANCE_CHART_DATA[1].data.map(d => d.y)); + + // Draw line + ctx.strokeStyle = MOCK_MAINTENANCE_CHART_DATA[1].color; + ctx.lineWidth = 2; + ctx.beginPath(); + + MOCK_MAINTENANCE_CHART_DATA[1].data.forEach((point, index) => { + const x = padding + (index * chartWidth) / (MOCK_MAINTENANCE_CHART_DATA[1].data.length - 1); + const y = padding + chartHeight - ((point.y / maxValue) * chartHeight); + + if (index === 0) { + ctx.moveTo(x, y); + } else { + ctx.lineTo(x, y); + } + + // Draw points + ctx.fillStyle = MOCK_MAINTENANCE_CHART_DATA[1].color; + ctx.beginPath(); + ctx.arc(x, y, 4, 0, 2 * Math.PI); + ctx.fill(); + + // Labels + ctx.fillStyle = '#374151'; + ctx.font = '12px sans-serif'; + ctx.textAlign = 'center'; + ctx.fillText(point.label, x, canvas.height - 10); + ctx.fillText(`${point.y}h`, x, y - 10); + }); + + ctx.stroke(); + + // Axes + ctx.strokeStyle = '#374151'; + ctx.lineWidth = 2; + ctx.beginPath(); + ctx.moveTo(padding, padding); + ctx.lineTo(padding, padding + chartHeight); + ctx.lineTo(padding + chartWidth, padding + chartHeight); + ctx.stroke(); + } + } + }} className="w-full h-full" /> +
+
+
+ + {/* Maintenance Schedule */} + +

Programación de Mantenimiento

+
+ {MOCK_EQUIPMENT.map((equipment) => { + const nextMaintenanceDate = new Date(equipment.nextMaintenance); + const daysUntilMaintenance = Math.ceil((nextMaintenanceDate.getTime() - new Date().getTime()) / (1000 * 60 * 60 * 24)); + const isOverdue = daysUntilMaintenance < 0; + + return ( +
+
+
+

{equipment.name}

+

{equipment.model}

+
+
+

+ {isOverdue ? 'Atrasado' : 'Programado'} +

+

+ {nextMaintenanceDate.toLocaleDateString('es-ES')} +

+
+
+
+
+ Técnico: + Juan Pérez +
+ +
+
+ ); + })} +
+
+
+ )} + + {/* Status Tab */} + {activeTab === 'status' && ( +
+ {/* Status Overview */} +
+ +
{equipmentStats.operational}
+
Operativo
+
+ +
{equipmentStats.warning}
+
Advertencia
+
+ +
{equipmentStats.maintenance}
+
Mantenimiento
+
+ +
{equipmentStats.down}
+
Fuera de Servicio
+
+
+ + {/* Active Alerts */} + +

Alertas Activas

+
+ {MOCK_EQUIPMENT.flatMap(eq => + eq.alerts.filter(a => !a.acknowledged).map(alert => ( +
+
+
+ + {eq.name} +
+ + {new Date(alert.timestamp).toLocaleString('es-ES')} + +
+

{alert.message}

+
+ +
+
+ )) + )} + + {MOCK_EQUIPMENT.flatMap(eq => eq.alerts.filter(a => !a.acknowledged)).length === 0 && ( +
+ +

No hay alertas activas

+

Todos los equipos están funcionando correctamente

+
+ )} +
+
+ + {/* Equipment Status Details */} + +

Detalles de Estado de Equipos

+
+ {MOCK_EQUIPMENT.map((equipment) => { + const statusConfig = getStatusConfig(equipment.status); + + return ( +
+
+
+
+
+

{equipment.name}

+

{equipment.model}

+
+
+ + {statusConfig.text} + +
+ +
+
+
{equipment.efficiency}%
+
Eficiencia
+
+
+
{equipment.uptime.toFixed(1)}%
+
Tiempo de Actividad
+
+
+
{equipment.energyUsage} kW
+
Consumo Energético
+
+
+
{equipment.utilizationToday}%
+
Utilización Hoy
+
+
+
+ ); + })} +
+ +
+ )}
-
-
-

Refrigerador

-

Temperatura estable

-
-
-
- - + + ); }; const ProductionAnalyticsPage: React.FC = () => { const [activeTab, setActiveTab] = useState('overview'); + const { canAccessAnalytics } = useSubscription(); + const currentTenant = useCurrentTenant(); + const tenantId = currentTenant?.id || ''; + + // Check if user has access to advanced analytics (professional/enterprise) + const hasAdvancedAccess = canAccessAnalytics('advanced'); + + // If user doesn't have access to advanced analytics, show upgrade message + if (!hasAdvancedAccess) { + return ( +
+ + + + +

+ Contenido exclusivo para planes Professional y Enterprise +

+

+ El análisis avanzado de producción está disponible solo para usuarios con planes Professional o Enterprise. + Actualiza tu plan para acceder a todas las funcionalidades. +

+ +
+
+ ); + } return (
@@ -175,14 +1006,14 @@ const ProductionAnalyticsPage: React.FC = () => { IA Insights - -
- - - {/* Chart or Table */} + {/* Chart View */}
- {viewMode === 'chart' ? ( - - ) : ( - - )} +
diff --git a/frontend/src/pages/app/operations/maquinaria/MaquinariaPage.tsx b/frontend/src/pages/app/operations/maquinaria/MaquinariaPage.tsx index d90ae82a..1e189bf7 100644 --- a/frontend/src/pages/app/operations/maquinaria/MaquinariaPage.tsx +++ b/frontend/src/pages/app/operations/maquinaria/MaquinariaPage.tsx @@ -1,7 +1,8 @@ import React, { useState, useMemo } from 'react'; import { useTranslation } from 'react-i18next'; -import { Plus, AlertTriangle, Settings, CheckCircle, Eye, Clock, Zap, Wrench, Thermometer, Activity, Search, Filter, Download, TrendingUp, Bell, History, Calendar, Edit, Trash2 } from 'lucide-react'; +import { Plus, AlertTriangle, Settings, CheckCircle, Eye, Wrench, Thermometer, Activity, Search, Filter, Bell, History, Calendar, Edit, Trash2 } from 'lucide-react'; import { Button, Input, Card, StatsGrid, StatusCard, getStatusColor } from '../../../../components/ui'; +import { Badge } from '../../../../components/ui/Badge'; import { LoadingSpinner } from '../../../../components/shared'; import { PageHeader } from '../../../../components/layout'; import { useCurrentTenant } from '../../../../stores/tenant.store'; @@ -200,8 +201,6 @@ const MaquinariaPage: React.FC = () => { const warning = MOCK_EQUIPMENT.filter(e => e.status === 'warning').length; const maintenance = MOCK_EQUIPMENT.filter(e => e.status === 'maintenance').length; const down = MOCK_EQUIPMENT.filter(e => e.status === 'down').length; - const avgEfficiency = MOCK_EQUIPMENT.reduce((sum, e) => sum + e.efficiency, 0) / total; - const avgUptime = MOCK_EQUIPMENT.reduce((sum, e) => sum + e.uptime, 0) / total; const totalAlerts = MOCK_EQUIPMENT.reduce((sum, e) => sum + e.alerts.filter(a => !a.acknowledged).length, 0); return { @@ -210,8 +209,6 @@ const MaquinariaPage: React.FC = () => { warning, maintenance, down, - avgEfficiency, - avgUptime, totalAlerts }; }, [MOCK_EQUIPMENT]); @@ -231,23 +228,13 @@ const MaquinariaPage: React.FC = () => { oven: Thermometer, mixer: Activity, proofer: Settings, - freezer: Zap, + freezer: Settings, packaging: Settings, other: Settings }; return icons[type]; }; - const getStatusColor = (status: string): string => { - const statusColors: { [key: string]: string } = { - operational: '#10B981', // green-500 - warning: '#F59E0B', // amber-500 - maintenance: '#3B82F6', // blue-500 - down: '#EF4444' // red-500 - }; - return statusColors[status] || '#6B7280'; // gray-500 as default - }; - const stats = [ { title: t('labels.total_equipment'), @@ -262,12 +249,6 @@ const MaquinariaPage: React.FC = () => { variant: 'success' as const, subtitle: `${((equipmentStats.operational / equipmentStats.total) * 100).toFixed(1)}%` }, - { - title: t('labels.avg_efficiency'), - value: `${equipmentStats.avgEfficiency.toFixed(1)}%`, - icon: TrendingUp, - variant: equipmentStats.avgEfficiency >= 90 ? 'success' as const : 'warning' as const - }, { title: t('labels.active_alerts'), value: equipmentStats.totalAlerts, diff --git a/frontend/src/pages/app/settings/profile/ProfilePage.tsx b/frontend/src/pages/app/settings/profile/ProfilePage.tsx index 1a2ff92b..2d529c99 100644 --- a/frontend/src/pages/app/settings/profile/ProfilePage.tsx +++ b/frontend/src/pages/app/settings/profile/ProfilePage.tsx @@ -264,7 +264,7 @@ const ProfilePage: React.FC = () => { // Subscription handlers const loadSubscriptionData = async () => { - let tenantId = currentTenant?.id || user?.tenant_id; + const tenantId = currentTenant?.id || user?.tenant_id; if (!tenantId) { addToast('No se encontró información del tenant', 'error'); @@ -294,7 +294,7 @@ const ProfilePage: React.FC = () => { }; const handleUpgradeConfirm = async () => { - let tenantId = currentTenant?.id || user?.tenant_id; + const tenantId = currentTenant?.id || user?.tenant_id; if (!tenantId || !selectedPlan) { addToast('Información de tenant no disponible', 'error');