Add improved production UI 4
This commit is contained in:
@@ -3,9 +3,15 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { useState, useEffect, useCallback } from 'react';
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
import { subscriptionService } from '../api';
|
import { subscriptionService } from '../services/subscription';
|
||||||
import { useCurrentTenant } from '../stores';
|
import {
|
||||||
import { useAuthUser } from '../stores/auth.store';
|
SUBSCRIPTION_PLANS,
|
||||||
|
ANALYTICS_LEVELS,
|
||||||
|
AnalyticsLevel,
|
||||||
|
SubscriptionPlanKey
|
||||||
|
} from '../types/subscription';
|
||||||
|
import { useCurrentTenant } from '../../stores';
|
||||||
|
import { useAuthUser } from '../../stores/auth.store';
|
||||||
|
|
||||||
export interface SubscriptionFeature {
|
export interface SubscriptionFeature {
|
||||||
hasFeature: boolean;
|
hasFeature: boolean;
|
||||||
@@ -95,15 +101,15 @@ export const useSubscription = () => {
|
|||||||
const getAnalyticsAccess = useCallback((): { hasAccess: boolean; level: string; reason?: string } => {
|
const getAnalyticsAccess = useCallback((): { hasAccess: boolean; level: string; reason?: string } => {
|
||||||
const { plan } = subscriptionInfo;
|
const { plan } = subscriptionInfo;
|
||||||
|
|
||||||
switch (plan) {
|
// Convert plan to typed plan key if it matches our known plans
|
||||||
case 'starter':
|
let planKey: keyof typeof SUBSCRIPTION_PLANS | undefined;
|
||||||
return { hasAccess: true, level: 'basic' };
|
if (plan === SUBSCRIPTION_PLANS.STARTER) planKey = SUBSCRIPTION_PLANS.STARTER;
|
||||||
case 'professional':
|
else if (plan === SUBSCRIPTION_PLANS.PROFESSIONAL) planKey = SUBSCRIPTION_PLANS.PROFESSIONAL;
|
||||||
return { hasAccess: true, level: 'advanced' };
|
else if (plan === SUBSCRIPTION_PLANS.ENTERPRISE) planKey = SUBSCRIPTION_PLANS.ENTERPRISE;
|
||||||
case 'enterprise':
|
|
||||||
return { hasAccess: true, level: 'predictive' };
|
if (planKey) {
|
||||||
default:
|
const analyticsLevel = subscriptionService.getAnalyticsLevelForPlan(planKey);
|
||||||
return { hasAccess: false, level: 'none', reason: 'No valid subscription plan' };
|
return { hasAccess: true, level: analyticsLevel };
|
||||||
}
|
}
|
||||||
}, [subscriptionInfo.plan]);
|
}, [subscriptionInfo.plan]);
|
||||||
|
|
||||||
@@ -113,13 +119,7 @@ export const useSubscription = () => {
|
|||||||
|
|
||||||
if (!hasAccess) return false;
|
if (!hasAccess) return false;
|
||||||
|
|
||||||
const levelHierarchy = {
|
return subscriptionService.doesAnalyticsLevelMeetMinimum(level as any, requiredLevel);
|
||||||
'basic': 1,
|
|
||||||
'advanced': 2,
|
|
||||||
'predictive': 3
|
|
||||||
};
|
|
||||||
|
|
||||||
return levelHierarchy[level as keyof typeof levelHierarchy] >= levelHierarchy[requiredLevel];
|
|
||||||
}, [getAnalyticsAccess]);
|
}, [getAnalyticsAccess]);
|
||||||
|
|
||||||
// Check if user can access forecasting features
|
// Check if user can access forecasting features
|
||||||
@@ -1,6 +1,3 @@
|
|||||||
/**
|
|
||||||
* Subscription Service - Mirror backend subscription endpoints
|
|
||||||
*/
|
|
||||||
import { apiClient } from '../client';
|
import { apiClient } from '../client';
|
||||||
import {
|
import {
|
||||||
SubscriptionLimits,
|
SubscriptionLimits,
|
||||||
@@ -9,9 +6,27 @@ import {
|
|||||||
UsageSummary,
|
UsageSummary,
|
||||||
AvailablePlans,
|
AvailablePlans,
|
||||||
PlanUpgradeValidation,
|
PlanUpgradeValidation,
|
||||||
PlanUpgradeResult
|
PlanUpgradeResult,
|
||||||
|
SUBSCRIPTION_PLANS,
|
||||||
|
ANALYTICS_LEVELS,
|
||||||
|
AnalyticsLevel,
|
||||||
|
SubscriptionPlanKey,
|
||||||
|
PLAN_HIERARCHY,
|
||||||
|
ANALYTICS_HIERARCHY
|
||||||
} from '../types/subscription';
|
} from '../types/subscription';
|
||||||
|
|
||||||
|
// Map plan keys to analytics levels based on backend data
|
||||||
|
const PLAN_TO_ANALYTICS_LEVEL: Record<SubscriptionPlanKey, AnalyticsLevel> = {
|
||||||
|
[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 {
|
export class SubscriptionService {
|
||||||
private readonly baseUrl = '/subscriptions';
|
private readonly baseUrl = '/subscriptions';
|
||||||
|
|
||||||
@@ -106,13 +121,119 @@ export class SubscriptionService {
|
|||||||
}).format(amount);
|
}).format(amount);
|
||||||
}
|
}
|
||||||
|
|
||||||
getPlanDisplayInfo(planKey: string): { name: string; color: string } {
|
/**
|
||||||
const planInfo = {
|
* Fetch available subscription plans from the backend
|
||||||
starter: { name: 'Starter', color: 'blue' },
|
*/
|
||||||
professional: { name: 'Professional', color: 'purple' },
|
async fetchAvailablePlans(): Promise<AvailablePlans> {
|
||||||
enterprise: { name: 'Enterprise', color: 'amber' }
|
const now = Date.now();
|
||||||
};
|
|
||||||
return planInfo[planKey as keyof typeof planInfo] || { name: 'Desconocido', color: 'gray' };
|
// Return cached data if it's still valid
|
||||||
|
if (cachedPlans && lastFetchTime && (now - lastFetchTime) < CACHE_DURATION) {
|
||||||
|
return cachedPlans;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const plans = await apiClient.get<AvailablePlans>('/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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -95,3 +95,37 @@ export interface PlanUpgradeResult {
|
|||||||
success: boolean;
|
success: boolean;
|
||||||
message: string;
|
message: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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<SubscriptionPlanKey, number> = {
|
||||||
|
[SUBSCRIPTION_PLANS.STARTER]: 1,
|
||||||
|
[SUBSCRIPTION_PLANS.PROFESSIONAL]: 2,
|
||||||
|
[SUBSCRIPTION_PLANS.ENTERPRISE]: 3
|
||||||
|
};
|
||||||
|
|
||||||
|
// Analytics level hierarchy
|
||||||
|
export const ANALYTICS_HIERARCHY: Record<AnalyticsLevel, number> = {
|
||||||
|
[ANALYTICS_LEVELS.NONE]: 0,
|
||||||
|
[ANALYTICS_LEVELS.BASIC]: 1,
|
||||||
|
[ANALYTICS_LEVELS.ADVANCED]: 2,
|
||||||
|
[ANALYTICS_LEVELS.PREDICTIVE]: 3
|
||||||
|
};
|
||||||
@@ -5,6 +5,11 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Modal, Button, Card } from '../ui';
|
import { Modal, Button, Card } from '../ui';
|
||||||
import { Crown, Lock, ArrowRight, AlertTriangle } from 'lucide-react';
|
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 {
|
interface SubscriptionError {
|
||||||
error: string;
|
error: string;
|
||||||
@@ -44,29 +49,29 @@ const SubscriptionErrorHandler: React.FC<SubscriptionErrorHandlerProps> = ({
|
|||||||
|
|
||||||
const getLevelDisplayName = (level: string) => {
|
const getLevelDisplayName = (level: string) => {
|
||||||
const levelNames: Record<string, string> = {
|
const levelNames: Record<string, string> = {
|
||||||
basic: 'Básico',
|
[ANALYTICS_LEVELS.BASIC]: 'Básico',
|
||||||
advanced: 'Avanzado',
|
[ANALYTICS_LEVELS.ADVANCED]: 'Avanzado',
|
||||||
predictive: 'Predictivo'
|
[ANALYTICS_LEVELS.PREDICTIVE]: 'Predictivo'
|
||||||
};
|
};
|
||||||
return levelNames[level] || level;
|
return levelNames[level] || level;
|
||||||
};
|
};
|
||||||
|
|
||||||
const getRequiredPlan = (level: string) => {
|
const getRequiredPlan = (level: string) => {
|
||||||
switch (level) {
|
switch (level) {
|
||||||
case 'advanced':
|
case ANALYTICS_LEVELS.ADVANCED:
|
||||||
return 'Professional';
|
return SUBSCRIPTION_PLANS.PROFESSIONAL;
|
||||||
case 'predictive':
|
case ANALYTICS_LEVELS.PREDICTIVE:
|
||||||
return 'Enterprise';
|
return SUBSCRIPTION_PLANS.ENTERPRISE;
|
||||||
default:
|
default:
|
||||||
return 'Professional';
|
return SUBSCRIPTION_PLANS.PROFESSIONAL;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const getPlanColor = (plan: string) => {
|
const getPlanColor = (plan: string) => {
|
||||||
switch (plan.toLowerCase()) {
|
switch (plan.toLowerCase()) {
|
||||||
case 'professional':
|
case SUBSCRIPTION_PLANS.PROFESSIONAL:
|
||||||
return 'bg-gradient-to-br from-purple-500 to-indigo-600';
|
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';
|
return 'bg-gradient-to-br from-yellow-400 to-orange-500';
|
||||||
default:
|
default:
|
||||||
return 'bg-gradient-to-br from-blue-500 to-cyan-600';
|
return 'bg-gradient-to-br from-blue-500 to-cyan-600';
|
||||||
|
|||||||
@@ -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<ProductionCostAnalyticsProps> = ({
|
||||||
|
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 (
|
||||||
|
<Card className={className}>
|
||||||
|
<CardHeader>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h3 className="text-lg font-semibold text-[var(--text-primary)]">
|
||||||
|
{t('production.cost.analytics.title', 'Cost Analytics')}
|
||||||
|
</h3>
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<Select
|
||||||
|
value={selectedTimeRange}
|
||||||
|
onChange={handleTimeRangeChange}
|
||||||
|
options={[
|
||||||
|
{ value: 'day', label: t('common.time_range.day', 'Today') },
|
||||||
|
{ value: 'week', label: t('common.time_range.week', 'This Week') },
|
||||||
|
{ value: 'month', label: t('common.time_range.month', 'This Month') },
|
||||||
|
{ value: 'quarter', label: t('common.time_range.quarter', 'This Quarter') },
|
||||||
|
{ value: 'year', label: t('common.time_range.year', 'This Year') }
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
<Button variant="outline" size="sm">
|
||||||
|
<Download className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardBody>
|
||||||
|
<div className="animate-pulse space-y-4">
|
||||||
|
<div className="h-20 bg-[var(--bg-secondary)] rounded"></div>
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div className="h-16 bg-[var(--bg-secondary)] rounded"></div>
|
||||||
|
<div className="h-16 bg-[var(--bg-secondary)] rounded"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardBody>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<Card className={className}>
|
||||||
|
<CardHeader>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h3 className="text-lg font-semibold text-[var(--text-primary)]">
|
||||||
|
{t('production.cost.analytics.title', 'Cost Analytics')}
|
||||||
|
</h3>
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<Select
|
||||||
|
value={selectedTimeRange}
|
||||||
|
onChange={handleTimeRangeChange}
|
||||||
|
options={[
|
||||||
|
{ value: 'day', label: t('common.time_range.day', 'Today') },
|
||||||
|
{ value: 'week', label: t('common.time_range.week', 'This Week') },
|
||||||
|
{ value: 'month', label: t('common.time_range.month', 'This Month') },
|
||||||
|
{ value: 'quarter', label: t('common.time_range.quarter', 'This Quarter') },
|
||||||
|
{ value: 'year', label: t('common.time_range.year', 'This Year') }
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
<Button variant="outline" size="sm">
|
||||||
|
<Download className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardBody className="text-center py-8">
|
||||||
|
<AlertTriangle className="w-12 h-12 text-red-500 mx-auto mb-4" />
|
||||||
|
<p className="text-[var(--text-secondary)]">
|
||||||
|
{t('production.cost.error', 'Error loading cost data')}
|
||||||
|
</p>
|
||||||
|
</CardBody>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex justify-between text-sm">
|
||||||
|
<span className="text-[var(--text-secondary)]">{formatCurrency(0)}</span>
|
||||||
|
<span className="text-[var(--text-secondary)]">{formatCurrency(maxValue)}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-end h-32 gap-1">
|
||||||
|
{costData.costHistory.map((item, index) => {
|
||||||
|
const height = (item.totalCost / maxValue) * 100;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={index}
|
||||||
|
className="flex-1 flex flex-col items-center group relative"
|
||||||
|
style={{ height: '100%' }}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="w-full bg-gradient-to-t from-[var(--color-primary)] to-[var(--color-primary)]/70 rounded-t hover:opacity-90 transition-all duration-200"
|
||||||
|
style={{ height: `${height}%` }}
|
||||||
|
></div>
|
||||||
|
<div className="absolute -top-8 left-1/2 transform -translate-x-1/2 bg-[var(--bg-tertiary)] px-2 py-1 rounded text-xs opacity-0 group-hover:opacity-100 transition-opacity duration-200 whitespace-nowrap">
|
||||||
|
{new Date(item.date).toLocaleDateString('es-ES')}<br/>{formatCurrency(item.totalCost)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className={className}>
|
||||||
|
<CardHeader>
|
||||||
|
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-lg font-semibold text-[var(--text-primary)]">
|
||||||
|
{t('production.cost.analytics.title', 'Cost Analytics')}
|
||||||
|
</h3>
|
||||||
|
<p className="text-sm text-[var(--text-secondary)]">
|
||||||
|
{t('production.cost.analytics.subtitle', 'Detailed cost breakdown and trends')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<Select
|
||||||
|
value={selectedTimeRange}
|
||||||
|
onChange={handleTimeRangeChange}
|
||||||
|
options={[
|
||||||
|
{ value: 'day', label: t('common.time_range.day', 'Today') },
|
||||||
|
{ value: 'week', label: t('common.time_range.week', 'This Week') },
|
||||||
|
{ value: 'month', label: t('common.time_range.month', 'This Month') },
|
||||||
|
{ value: 'quarter', label: t('common.time_range.quarter', 'This Quarter') },
|
||||||
|
{ value: 'year', label: t('common.time_range.year', 'This Year') }
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={onExport}
|
||||||
|
disabled={!onExport}
|
||||||
|
>
|
||||||
|
<Download className="w-4 h-4 mr-1" />
|
||||||
|
{t('common.export', 'Export')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between mt-4 pt-4 border-t border-[var(--border-primary)]">
|
||||||
|
<div className="flex items-center space-x-3">
|
||||||
|
<Badge variant={budgetStatus.color as 'success' | 'warning' | 'error' | 'default' | 'info'}>
|
||||||
|
{budgetStatus.label}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<div className="text-sm text-[var(--text-secondary)]">
|
||||||
|
{t('production.cost.last_updated', 'Updated today')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
|
||||||
|
<CardBody className="space-y-8">
|
||||||
|
{/* Key Metrics */}
|
||||||
|
<StatsGrid
|
||||||
|
stats={costStats}
|
||||||
|
columns={2}
|
||||||
|
gap="md"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Cost Trend Chart */}
|
||||||
|
<div>
|
||||||
|
<h4 className="text-sm font-medium text-[var(--text-primary)] mb-4 flex items-center">
|
||||||
|
<TrendingUp className="w-4 h-4 mr-2" />
|
||||||
|
{t('production.cost.trend', 'Cost Trend')}
|
||||||
|
</h4>
|
||||||
|
{renderCostChart()}
|
||||||
|
<div className="mt-2 text-xs text-[var(--text-tertiary)] text-center">
|
||||||
|
{t('production.cost.trend_description', 'Total cost trend over time')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Cost Breakdown */}
|
||||||
|
<div>
|
||||||
|
<h4 className="text-sm font-medium text-[var(--text-primary)] mb-4 flex items-center">
|
||||||
|
<Euro className="w-4 h-4 mr-2" />
|
||||||
|
{t('production.cost.breakdown', 'Cost Breakdown')}
|
||||||
|
</h4>
|
||||||
|
<div className="space-y-3">
|
||||||
|
{costBreakdown.map((item, index) => {
|
||||||
|
const Icon = item.icon;
|
||||||
|
return (
|
||||||
|
<div key={index} className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center space-x-3">
|
||||||
|
<div className={`w-3 h-3 rounded-full ${item.color}`}></div>
|
||||||
|
<Icon className="w-4 h-4 text-[var(--text-tertiary)]" />
|
||||||
|
<span className="text-sm text-[var(--text-primary)]">
|
||||||
|
{item.label}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center space-x-3">
|
||||||
|
<span className="text-sm font-medium text-[var(--text-primary)]">
|
||||||
|
{formatCurrency(item.amount)}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-[var(--text-tertiary)] w-10 text-right">
|
||||||
|
{item.percentage.toFixed(1)}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Budget Progress */}
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center justify-between mb-2">
|
||||||
|
<span className="text-sm font-medium text-[var(--text-primary)]">
|
||||||
|
{t('production.cost.budget_usage', 'Budget Usage')}
|
||||||
|
</span>
|
||||||
|
<span className="text-sm text-[var(--text-secondary)]">
|
||||||
|
{formatCurrency(costData.totalCost)} / {formatCurrency(costData.budget)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="w-full bg-[var(--bg-secondary)] rounded-full h-2.5">
|
||||||
|
<div
|
||||||
|
className={`h-2.5 rounded-full transition-all duration-300 ${costData.totalCost > costData.budget ? 'bg-red-500' : costData.totalCost > costData.budget * 0.9 ? 'bg-yellow-500' : 'bg-green-500'}`}
|
||||||
|
style={{
|
||||||
|
width: `${Math.min(100, (costData.totalCost / costData.budget) * 100)}%`
|
||||||
|
}}
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between mt-2">
|
||||||
|
<span className={`text-xs font-medium ${getVarianceColor(costData.variance)}`}>
|
||||||
|
{costData.variance > 0 ? '+' : ''}{formatCurrency(costData.variance)}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-[var(--text-tertiary)]">
|
||||||
|
{((costData.totalCost / costData.budget) * 100).toFixed(1)}% {t('common.used', 'used')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
<div className="flex flex-col sm:flex-row space-y-2 sm:space-y-0 sm:space-x-3 pt-4 border-t border-[var(--border-primary)]">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={onViewDetails}
|
||||||
|
className="flex-1"
|
||||||
|
>
|
||||||
|
{t('production.cost.view_details', 'View Detailed Report')}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
onClick={onOptimize}
|
||||||
|
className="flex-1"
|
||||||
|
>
|
||||||
|
{t('production.cost.optimize', 'Optimize Costs')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardBody>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ProductionCostAnalytics;
|
||||||
@@ -63,7 +63,7 @@ export const ReportsTable: React.FC<ReportsTableProps> = ({
|
|||||||
const [reportToShare, setReportToShare] = useState<AnalyticsReport | null>(null);
|
const [reportToShare, setReportToShare] = useState<AnalyticsReport | null>(null);
|
||||||
|
|
||||||
const filteredAndSortedReports = useMemo(() => {
|
const filteredAndSortedReports = useMemo(() => {
|
||||||
let filtered = reports.filter(report => {
|
const filtered = reports.filter(report => {
|
||||||
const matchesSearch = !searchTerm ||
|
const matchesSearch = !searchTerm ||
|
||||||
report.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
report.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||||
report.description?.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
report.description?.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ export { default as ChartWidget } from './ChartWidget';
|
|||||||
export { default as ReportsTable } from './ReportsTable';
|
export { default as ReportsTable } from './ReportsTable';
|
||||||
export { default as FilterPanel } from './FilterPanel';
|
export { default as FilterPanel } from './FilterPanel';
|
||||||
export { default as ExportOptions } from './ExportOptions';
|
export { default as ExportOptions } from './ExportOptions';
|
||||||
|
export { default as ProductionCostAnalytics } from './ProductionCostAnalytics';
|
||||||
|
|
||||||
// Types
|
// Types
|
||||||
export * from './types';
|
export * from './types';
|
||||||
@@ -25,11 +25,11 @@ const Button = forwardRef<HTMLButtonElement, ButtonProps>(({
|
|||||||
...props
|
...props
|
||||||
}, ref) => {
|
}, ref) => {
|
||||||
const baseClasses = [
|
const baseClasses = [
|
||||||
'inline-flex items-center justify-center font-semibold tracking-wide',
|
'inline-flex items-center justify-center font-medium',
|
||||||
'transition-all duration-200 ease-in-out',
|
'transition-all duration-200 ease-in-out',
|
||||||
'focus:outline-none focus:ring-2 focus:ring-offset-2',
|
'focus:outline-none focus:ring-2 focus:ring-offset-2',
|
||||||
'disabled:opacity-50 disabled:cursor-not-allowed',
|
'disabled:opacity-50 disabled:cursor-not-allowed',
|
||||||
'border rounded-xl shadow-sm',
|
'border rounded-md shadow-sm',
|
||||||
'hover:shadow-md active:shadow-sm'
|
'hover:shadow-md active:shadow-sm'
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -53,10 +53,10 @@ const Button = forwardRef<HTMLButtonElement, ButtonProps>(({
|
|||||||
'active:bg-[var(--color-primary-dark)] active:border-[var(--color-primary-dark)]'
|
'active:bg-[var(--color-primary-dark)] active:border-[var(--color-primary-dark)]'
|
||||||
],
|
],
|
||||||
ghost: [
|
ghost: [
|
||||||
'bg-transparent text-[var(--color-primary)] border-transparent',
|
'bg-transparent text-[var(--text-primary)] border-transparent',
|
||||||
'hover:bg-[var(--color-primary)]/10 hover:text-[var(--color-primary-dark)]',
|
'hover:bg-[var(--bg-tertiary)] hover:text-[var(--text-primary)]',
|
||||||
'focus:ring-[var(--color-primary)]/20',
|
'focus:ring-[var(--color-primary)]/20',
|
||||||
'active:bg-[var(--color-primary)]/20'
|
'active:bg-[var(--bg-secondary)]'
|
||||||
],
|
],
|
||||||
danger: [
|
danger: [
|
||||||
'bg-[var(--color-error)] text-[var(--text-inverse)] border-[var(--color-error)]',
|
'bg-[var(--color-error)] text-[var(--text-inverse)] border-[var(--color-error)]',
|
||||||
@@ -79,11 +79,11 @@ const Button = forwardRef<HTMLButtonElement, ButtonProps>(({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const sizeClasses = {
|
const sizeClasses = {
|
||||||
xs: 'px-3 py-1.5 text-xs gap-1.5 min-h-7',
|
xs: 'px-2 py-1 text-xs gap-1 min-h-[1.75rem]',
|
||||||
sm: 'px-4 py-2 text-sm gap-2 min-h-9 sm:min-h-10',
|
sm: 'px-3 py-1.5 text-sm gap-1.5 min-h-[2.25rem]',
|
||||||
md: 'px-6 py-3 text-sm gap-2.5 min-h-11 sm:min-h-12',
|
md: 'px-4 py-2 text-sm gap-2 min-h-[2.75rem]',
|
||||||
lg: 'px-8 py-3.5 text-base gap-3 min-h-13 sm:min-h-14',
|
lg: 'px-5 py-2.5 text-base gap-2.5 min-h-[3.25rem]',
|
||||||
xl: 'px-10 py-4 text-lg gap-3.5 min-h-15 sm:min-h-16'
|
xl: 'px-6 py-3 text-lg gap-3 min-h-[3.75rem]'
|
||||||
};
|
};
|
||||||
|
|
||||||
const loadingSpinner = (
|
const loadingSpinner = (
|
||||||
@@ -129,13 +129,13 @@ const Button = forwardRef<HTMLButtonElement, ButtonProps>(({
|
|||||||
>
|
>
|
||||||
{isLoading && loadingSpinner}
|
{isLoading && loadingSpinner}
|
||||||
{!isLoading && leftIcon && (
|
{!isLoading && leftIcon && (
|
||||||
<span className="flex-shrink-0 w-5 h-5 flex items-center justify-center">{leftIcon}</span>
|
<span className="flex-shrink-0 w-4 h-4 flex items-center justify-center">{leftIcon}</span>
|
||||||
)}
|
)}
|
||||||
<span className="relative">
|
<span className="relative">
|
||||||
{isLoading && loadingText ? loadingText : children}
|
{isLoading && loadingText ? loadingText : children}
|
||||||
</span>
|
</span>
|
||||||
{!isLoading && rightIcon && (
|
{!isLoading && rightIcon && (
|
||||||
<span className="flex-shrink-0 w-5 h-5 flex items-center justify-center">{rightIcon}</span>
|
<span className="flex-shrink-0 w-4 h-4 flex items-center justify-center">{rightIcon}</span>
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -232,7 +232,7 @@ const DatePicker = forwardRef<HTMLInputElement, DatePickerProps>(({
|
|||||||
const handleDateSelect = (date: Date) => {
|
const handleDateSelect = (date: Date) => {
|
||||||
if (isDateDisabled(date)) return;
|
if (isDateDisabled(date)) return;
|
||||||
|
|
||||||
let newDate = new Date(date);
|
const newDate = new Date(date);
|
||||||
|
|
||||||
// Preserve time if time is shown and a previous value exists
|
// Preserve time if time is shown and a previous value exists
|
||||||
if (showTime && currentValue) {
|
if (showTime && currentValue) {
|
||||||
|
|||||||
@@ -531,7 +531,7 @@ const TablePagination: React.FC<TablePaginationProps> = ({
|
|||||||
const buttons = [];
|
const buttons = [];
|
||||||
const maxVisible = 7;
|
const maxVisible = 7;
|
||||||
let startPage = Math.max(1, current - Math.floor(maxVisible / 2));
|
let startPage = Math.max(1, current - Math.floor(maxVisible / 2));
|
||||||
let endPage = Math.min(totalPages, startPage + maxVisible - 1);
|
const endPage = Math.min(totalPages, startPage + maxVisible - 1);
|
||||||
|
|
||||||
if (endPage - startPage + 1 < maxVisible) {
|
if (endPage - startPage + 1 < maxVisible) {
|
||||||
startPage = Math.max(1, endPage - maxVisible + 1);
|
startPage = Math.max(1, endPage - maxVisible + 1);
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
// Export commonly used hooks
|
// Export commonly used hooks
|
||||||
export { default as useLocalStorage } from './useLocalStorage';
|
export { default as useLocalStorage } from './useLocalStorage';
|
||||||
export { default as useDebounce } from './useDebounce';
|
export { default as useDebounce } from './useDebounce';
|
||||||
export { default as useSubscription } from './useSubscription';
|
export { default as useSubscription } from '../api/hooks/subscription';
|
||||||
export { useTenantId, useTenantInfo, useRequiredTenant } from './useTenantId';
|
export { useTenantId, useTenantInfo, useRequiredTenant } from './useTenantId';
|
||||||
@@ -4,7 +4,7 @@
|
|||||||
|
|
||||||
import { useMemo } from 'react';
|
import { useMemo } from 'react';
|
||||||
import { RouteConfig } from '../router/routes.config';
|
import { RouteConfig } from '../router/routes.config';
|
||||||
import { useSubscription } from './useSubscription';
|
import { useSubscription } from '../api/hooks/subscription';
|
||||||
|
|
||||||
export const useSubscriptionAwareRoutes = (routes: RouteConfig[]) => {
|
export const useSubscriptionAwareRoutes = (routes: RouteConfig[]) => {
|
||||||
const { subscriptionInfo, canAccessAnalytics } = useSubscription();
|
const { subscriptionInfo, canAccessAnalytics } = useSubscription();
|
||||||
|
|||||||
@@ -8,17 +8,12 @@ import StatsGrid from '../../components/ui/Stats/StatsGrid';
|
|||||||
import RealTimeAlerts from '../../components/domain/dashboard/RealTimeAlerts';
|
import RealTimeAlerts from '../../components/domain/dashboard/RealTimeAlerts';
|
||||||
import ProcurementPlansToday from '../../components/domain/dashboard/ProcurementPlansToday';
|
import ProcurementPlansToday from '../../components/domain/dashboard/ProcurementPlansToday';
|
||||||
import ProductionPlansToday from '../../components/domain/dashboard/ProductionPlansToday';
|
import ProductionPlansToday from '../../components/domain/dashboard/ProductionPlansToday';
|
||||||
import ProductionCostMonitor from '../../components/domain/dashboard/ProductionCostMonitor';
|
|
||||||
import EquipmentStatusWidget from '../../components/domain/dashboard/EquipmentStatusWidget';
|
|
||||||
import AIInsightsWidget from '../../components/domain/dashboard/AIInsightsWidget';
|
|
||||||
import { useTenant } from '../../stores/tenant.store';
|
import { useTenant } from '../../stores/tenant.store';
|
||||||
import {
|
import {
|
||||||
AlertTriangle,
|
AlertTriangle,
|
||||||
Clock,
|
Clock,
|
||||||
Euro,
|
Euro,
|
||||||
Package,
|
Package,
|
||||||
TrendingUp,
|
|
||||||
TrendingDown,
|
|
||||||
Plus,
|
Plus,
|
||||||
Building2
|
Building2
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
@@ -173,15 +168,6 @@ const DashboardPage: React.FC = () => {
|
|||||||
onViewDetails={handleViewDetails}
|
onViewDetails={handleViewDetails}
|
||||||
onViewAllPlans={handleViewAllPlans}
|
onViewAllPlans={handleViewAllPlans}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* 4. Production Cost Monitor */}
|
|
||||||
<ProductionCostMonitor />
|
|
||||||
|
|
||||||
{/* 5. Equipment Status Widget */}
|
|
||||||
<EquipmentStatusWidget />
|
|
||||||
|
|
||||||
{/* 6. AI Insights Widget */}
|
|
||||||
<AIInsightsWidget />
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,10 +1,9 @@
|
|||||||
import React, { useState, useMemo } from 'react';
|
import React, { useState, useMemo } from 'react';
|
||||||
import { Calendar, TrendingUp, AlertTriangle, BarChart3, Download, Settings, Loader, Zap, Brain, Target, CloudRain, Sun, Thermometer, Package, Activity, Clock } from 'lucide-react';
|
import { Calendar, TrendingUp, AlertTriangle, BarChart3, Settings, Loader, Zap, Brain, Target, Activity } from 'lucide-react';
|
||||||
import { Button, Card, Badge, Table, StatsGrid, StatusCard, getStatusColor } from '../../../../components/ui';
|
import { Button, Card, Badge, StatsGrid, StatusCard, getStatusColor } from '../../../../components/ui';
|
||||||
import type { TableColumn } from '../../../../components/ui';
|
|
||||||
import { PageHeader } from '../../../../components/layout';
|
import { PageHeader } from '../../../../components/layout';
|
||||||
import { LoadingSpinner } from '../../../../components/shared';
|
import { LoadingSpinner } from '../../../../components/shared';
|
||||||
import { DemandChart, ForecastTable } from '../../../../components/domain/forecasting';
|
import { DemandChart } from '../../../../components/domain/forecasting';
|
||||||
import { useTenantForecasts, useCreateSingleForecast } from '../../../../api/hooks/forecasting';
|
import { useTenantForecasts, useCreateSingleForecast } from '../../../../api/hooks/forecasting';
|
||||||
import { useIngredients } from '../../../../api/hooks/inventory';
|
import { useIngredients } from '../../../../api/hooks/inventory';
|
||||||
import { useModels } from '../../../../api/hooks/training';
|
import { useModels } from '../../../../api/hooks/training';
|
||||||
@@ -16,7 +15,6 @@ import { formatters } from '../../../../components/ui/Stats/StatsPresets';
|
|||||||
const ForecastingPage: React.FC = () => {
|
const ForecastingPage: React.FC = () => {
|
||||||
const [selectedProduct, setSelectedProduct] = useState('');
|
const [selectedProduct, setSelectedProduct] = useState('');
|
||||||
const [forecastPeriod, setForecastPeriod] = useState('7');
|
const [forecastPeriod, setForecastPeriod] = useState('7');
|
||||||
const [viewMode, setViewMode] = useState<'chart' | 'table'>('chart');
|
|
||||||
const [isGenerating, setIsGenerating] = useState(false);
|
const [isGenerating, setIsGenerating] = useState(false);
|
||||||
const [hasGeneratedForecast, setHasGeneratedForecast] = useState(false);
|
const [hasGeneratedForecast, setHasGeneratedForecast] = useState(false);
|
||||||
const [currentForecastData, setCurrentForecastData] = useState<ForecastResponse[]>([]);
|
const [currentForecastData, setCurrentForecastData] = useState<ForecastResponse[]>([]);
|
||||||
@@ -132,63 +130,8 @@ const ForecastingPage: React.FC = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Transform forecast data for table display - only real data
|
|
||||||
const transformForecastsForTable = (forecasts: ForecastResponse[]) => {
|
|
||||||
return forecasts.map(forecast => ({
|
|
||||||
id: forecast.id,
|
|
||||||
product: forecast.inventory_product_id,
|
|
||||||
forecastDate: forecast.forecast_date,
|
|
||||||
forecastDemand: forecast.predicted_demand,
|
|
||||||
confidence: Math.round(forecast.confidence_level * 100),
|
|
||||||
confidenceRange: `${forecast.confidence_lower?.toFixed(1) || 'N/A'} - ${forecast.confidence_upper?.toFixed(1) || 'N/A'}`,
|
|
||||||
algorithm: forecast.algorithm,
|
|
||||||
}));
|
|
||||||
};
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
const forecastColumns: TableColumn[] = [
|
|
||||||
{
|
|
||||||
key: 'product',
|
|
||||||
title: 'Producto ID',
|
|
||||||
dataIndex: 'product',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'forecastDate',
|
|
||||||
title: 'Fecha',
|
|
||||||
dataIndex: 'forecastDate',
|
|
||||||
render: (value) => new Date(value).toLocaleDateString('es-ES'),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'forecastDemand',
|
|
||||||
title: 'Demanda Prevista',
|
|
||||||
dataIndex: 'forecastDemand',
|
|
||||||
render: (value) => (
|
|
||||||
<span className="font-medium text-[var(--color-info)]">{value?.toFixed(2) || 'N/A'}</span>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'confidence',
|
|
||||||
title: 'Confianza',
|
|
||||||
dataIndex: 'confidence',
|
|
||||||
render: (value) => `${value}%`,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'confidenceRange',
|
|
||||||
title: 'Rango de Confianza',
|
|
||||||
dataIndex: 'confidenceRange',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'algorithm',
|
|
||||||
title: 'Algoritmo',
|
|
||||||
dataIndex: 'algorithm',
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
// Use either current forecast data or fetched data
|
// Use either current forecast data or fetched data
|
||||||
const forecasts = currentForecastData.length > 0 ? currentForecastData : (forecastsData?.forecasts || []);
|
const forecasts = currentForecastData.length > 0 ? currentForecastData : (forecastsData?.forecasts || []);
|
||||||
const transformedForecasts = transformForecastsForTable(forecasts);
|
|
||||||
const isLoading = forecastsLoading || ingredientsLoading || modelsLoading || isGenerating;
|
const isLoading = forecastsLoading || ingredientsLoading || modelsLoading || isGenerating;
|
||||||
const hasError = forecastsError || ingredientsError || modelsError;
|
const hasError = forecastsError || ingredientsError || modelsError;
|
||||||
|
|
||||||
@@ -467,47 +410,19 @@ const ForecastingPage: React.FC = () => {
|
|||||||
{products.find(p => p.id === selectedProduct)?.name} • {forecastPeriod} días
|
{products.find(p => p.id === selectedProduct)?.name} • {forecastPeriod} días
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center space-x-3">
|
|
||||||
<div className="flex rounded-lg border border-[var(--border-secondary)] overflow-hidden">
|
|
||||||
<Button
|
|
||||||
variant={viewMode === 'chart' ? 'primary' : 'outline'}
|
|
||||||
onClick={() => setViewMode('chart')}
|
|
||||||
size="sm"
|
|
||||||
>
|
|
||||||
<BarChart3 className="w-4 h-4 mr-1" />
|
|
||||||
Gráfico
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant={viewMode === 'table' ? 'primary' : 'outline'}
|
|
||||||
onClick={() => setViewMode('table')}
|
|
||||||
size="sm"
|
|
||||||
>
|
|
||||||
<Package className="w-4 h-4 mr-1" />
|
|
||||||
Tabla
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
<Button variant="outline" size="sm">
|
|
||||||
<Download className="w-4 h-4 mr-2" />
|
|
||||||
Exportar
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Chart or Table */}
|
{/* Chart View */}
|
||||||
<div className="min-h-96">
|
<div className="min-h-96">
|
||||||
{viewMode === 'chart' ? (
|
<DemandChart
|
||||||
<DemandChart
|
data={forecasts}
|
||||||
data={forecasts}
|
product={selectedProduct}
|
||||||
product={selectedProduct}
|
period={forecastPeriod}
|
||||||
period={forecastPeriod}
|
loading={isLoading}
|
||||||
loading={isLoading}
|
error={hasError ? 'Error al cargar las predicciones' : null}
|
||||||
error={hasError ? 'Error al cargar las predicciones' : null}
|
height={400}
|
||||||
height={400}
|
title=""
|
||||||
title=""
|
/>
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<ForecastTable forecasts={transformedForecasts} />
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import React, { useState, useMemo } from 'react';
|
import React, { useState, useMemo } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
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 { Button, Input, Card, StatsGrid, StatusCard, getStatusColor } from '../../../../components/ui';
|
||||||
|
import { Badge } from '../../../../components/ui/Badge';
|
||||||
import { LoadingSpinner } from '../../../../components/shared';
|
import { LoadingSpinner } from '../../../../components/shared';
|
||||||
import { PageHeader } from '../../../../components/layout';
|
import { PageHeader } from '../../../../components/layout';
|
||||||
import { useCurrentTenant } from '../../../../stores/tenant.store';
|
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 warning = MOCK_EQUIPMENT.filter(e => e.status === 'warning').length;
|
||||||
const maintenance = MOCK_EQUIPMENT.filter(e => e.status === 'maintenance').length;
|
const maintenance = MOCK_EQUIPMENT.filter(e => e.status === 'maintenance').length;
|
||||||
const down = MOCK_EQUIPMENT.filter(e => e.status === 'down').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);
|
const totalAlerts = MOCK_EQUIPMENT.reduce((sum, e) => sum + e.alerts.filter(a => !a.acknowledged).length, 0);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -210,8 +209,6 @@ const MaquinariaPage: React.FC = () => {
|
|||||||
warning,
|
warning,
|
||||||
maintenance,
|
maintenance,
|
||||||
down,
|
down,
|
||||||
avgEfficiency,
|
|
||||||
avgUptime,
|
|
||||||
totalAlerts
|
totalAlerts
|
||||||
};
|
};
|
||||||
}, [MOCK_EQUIPMENT]);
|
}, [MOCK_EQUIPMENT]);
|
||||||
@@ -231,23 +228,13 @@ const MaquinariaPage: React.FC = () => {
|
|||||||
oven: Thermometer,
|
oven: Thermometer,
|
||||||
mixer: Activity,
|
mixer: Activity,
|
||||||
proofer: Settings,
|
proofer: Settings,
|
||||||
freezer: Zap,
|
freezer: Settings,
|
||||||
packaging: Settings,
|
packaging: Settings,
|
||||||
other: Settings
|
other: Settings
|
||||||
};
|
};
|
||||||
return icons[type];
|
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 = [
|
const stats = [
|
||||||
{
|
{
|
||||||
title: t('labels.total_equipment'),
|
title: t('labels.total_equipment'),
|
||||||
@@ -262,12 +249,6 @@ const MaquinariaPage: React.FC = () => {
|
|||||||
variant: 'success' as const,
|
variant: 'success' as const,
|
||||||
subtitle: `${((equipmentStats.operational / equipmentStats.total) * 100).toFixed(1)}%`
|
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'),
|
title: t('labels.active_alerts'),
|
||||||
value: equipmentStats.totalAlerts,
|
value: equipmentStats.totalAlerts,
|
||||||
|
|||||||
@@ -264,7 +264,7 @@ const ProfilePage: React.FC = () => {
|
|||||||
|
|
||||||
// Subscription handlers
|
// Subscription handlers
|
||||||
const loadSubscriptionData = async () => {
|
const loadSubscriptionData = async () => {
|
||||||
let tenantId = currentTenant?.id || user?.tenant_id;
|
const tenantId = currentTenant?.id || user?.tenant_id;
|
||||||
|
|
||||||
if (!tenantId) {
|
if (!tenantId) {
|
||||||
addToast('No se encontró información del tenant', 'error');
|
addToast('No se encontró información del tenant', 'error');
|
||||||
@@ -294,7 +294,7 @@ const ProfilePage: React.FC = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleUpgradeConfirm = async () => {
|
const handleUpgradeConfirm = async () => {
|
||||||
let tenantId = currentTenant?.id || user?.tenant_id;
|
const tenantId = currentTenant?.id || user?.tenant_id;
|
||||||
|
|
||||||
if (!tenantId || !selectedPlan) {
|
if (!tenantId || !selectedPlan) {
|
||||||
addToast('Información de tenant no disponible', 'error');
|
addToast('Información de tenant no disponible', 'error');
|
||||||
|
|||||||
Reference in New Issue
Block a user