Add improved production UI
This commit is contained in:
408
frontend/src/components/domain/dashboard/AIInsightsWidget.tsx
Normal file
408
frontend/src/components/domain/dashboard/AIInsightsWidget.tsx
Normal file
@@ -0,0 +1,408 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Card, CardHeader, CardBody } from '../../ui/Card';
|
||||
import { Badge } from '../../ui/Badge';
|
||||
import { Button } from '../../ui/Button';
|
||||
import {
|
||||
Brain,
|
||||
TrendingUp,
|
||||
TrendingDown,
|
||||
AlertTriangle,
|
||||
Target,
|
||||
Lightbulb,
|
||||
BarChart3,
|
||||
Zap,
|
||||
Clock,
|
||||
DollarSign,
|
||||
Users,
|
||||
Package
|
||||
} from 'lucide-react';
|
||||
import { useCurrentTenant } from '../../../stores/tenant.store';
|
||||
import { useProductionDashboard } from '../../../api';
|
||||
|
||||
export interface AIInsight {
|
||||
id: string;
|
||||
type: 'optimization' | 'prediction' | 'alert' | 'recommendation';
|
||||
title: string;
|
||||
description: string;
|
||||
impact: 'high' | 'medium' | 'low';
|
||||
category: 'cost' | 'quality' | 'efficiency' | 'demand' | 'maintenance';
|
||||
confidence: number;
|
||||
potentialSavings?: number;
|
||||
timeToImplement?: string;
|
||||
actions: Array<{
|
||||
label: string;
|
||||
action: string;
|
||||
}>;
|
||||
data?: {
|
||||
current: number;
|
||||
predicted: number;
|
||||
unit: string;
|
||||
};
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface AIInsightsWidgetProps {
|
||||
className?: string;
|
||||
insights?: AIInsight[];
|
||||
onViewInsight?: (insightId: string) => void;
|
||||
onImplement?: (insightId: string, actionId: string) => void;
|
||||
onViewAll?: () => void;
|
||||
}
|
||||
|
||||
const AIInsightsWidget: React.FC<AIInsightsWidgetProps> = ({
|
||||
className,
|
||||
insights,
|
||||
onViewInsight,
|
||||
onImplement,
|
||||
onViewAll
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const currentTenant = useCurrentTenant();
|
||||
const tenantId = currentTenant?.id || '';
|
||||
|
||||
const {
|
||||
data: dashboardData,
|
||||
isLoading,
|
||||
error
|
||||
} = useProductionDashboard(tenantId);
|
||||
|
||||
const aiInsights = useMemo((): AIInsight[] => {
|
||||
if (insights) return insights;
|
||||
|
||||
// Mock AI-generated insights for demonstration
|
||||
return [
|
||||
{
|
||||
id: '1',
|
||||
type: 'optimization',
|
||||
title: t('ai.insights.reduce_energy_costs', 'Reduce Energy Costs by 15%'),
|
||||
description: t('ai.insights.energy_description', 'Adjust oven schedules to use off-peak electricity rates. Estimated savings: <20>45/day'),
|
||||
impact: 'high',
|
||||
category: 'cost',
|
||||
confidence: 87,
|
||||
potentialSavings: 1350,
|
||||
timeToImplement: '2 days',
|
||||
actions: [
|
||||
{ label: t('ai.actions.schedule_optimization', 'Optimize Schedule'), action: 'optimize_schedule' },
|
||||
{ label: t('ai.actions.view_details', 'View Details'), action: 'view_details' }
|
||||
],
|
||||
data: {
|
||||
current: 95.2,
|
||||
predicted: 81.0,
|
||||
unit: '<27>/day'
|
||||
},
|
||||
createdAt: '2024-01-23T08:30:00Z'
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
type: 'prediction',
|
||||
title: t('ai.insights.demand_increase', 'Croissant Demand Surge Predicted'),
|
||||
description: t('ai.insights.demand_description', 'Weather and event data suggest 40% increase in croissant demand this weekend'),
|
||||
impact: 'high',
|
||||
category: 'demand',
|
||||
confidence: 92,
|
||||
timeToImplement: '1 day',
|
||||
actions: [
|
||||
{ label: t('ai.actions.increase_production', 'Increase Production'), action: 'adjust_production' },
|
||||
{ label: t('ai.actions.order_ingredients', 'Order Ingredients'), action: 'order_ingredients' }
|
||||
],
|
||||
data: {
|
||||
current: 150,
|
||||
predicted: 210,
|
||||
unit: 'units/day'
|
||||
},
|
||||
createdAt: '2024-01-23T07:15:00Z'
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
type: 'alert',
|
||||
title: t('ai.insights.quality_decline', 'Quality Score Decline Detected'),
|
||||
description: t('ai.insights.quality_description', 'Bread quality has decreased by 8% over the last 3 days. Check flour moisture levels'),
|
||||
impact: 'medium',
|
||||
category: 'quality',
|
||||
confidence: 78,
|
||||
timeToImplement: '4 hours',
|
||||
actions: [
|
||||
{ label: t('ai.actions.check_ingredients', 'Check Ingredients'), action: 'quality_check' },
|
||||
{ label: t('ai.actions.adjust_recipe', 'Adjust Recipe'), action: 'recipe_adjustment' }
|
||||
],
|
||||
data: {
|
||||
current: 8.2,
|
||||
predicted: 7.5,
|
||||
unit: 'score'
|
||||
},
|
||||
createdAt: '2024-01-23T06:45:00Z'
|
||||
},
|
||||
{
|
||||
id: '4',
|
||||
type: 'recommendation',
|
||||
title: t('ai.insights.maintenance_due', 'Preventive Maintenance Recommended'),
|
||||
description: t('ai.insights.maintenance_description', 'Mixer #2 showing early wear patterns. Schedule maintenance to prevent breakdown'),
|
||||
impact: 'medium',
|
||||
category: 'maintenance',
|
||||
confidence: 85,
|
||||
potentialSavings: 800,
|
||||
timeToImplement: '1 week',
|
||||
actions: [
|
||||
{ label: t('ai.actions.schedule_maintenance', 'Schedule Maintenance'), action: 'schedule_maintenance' },
|
||||
{ label: t('ai.actions.view_equipment', 'View Equipment'), action: 'view_equipment' }
|
||||
],
|
||||
createdAt: '2024-01-22T14:20:00Z'
|
||||
}
|
||||
];
|
||||
}, [insights, t]);
|
||||
|
||||
const getInsightConfig = (type: AIInsight['type']) => {
|
||||
const configs = {
|
||||
optimization: {
|
||||
color: 'success' as const,
|
||||
icon: TrendingUp,
|
||||
bgColor: 'bg-green-50 dark:bg-green-900/20',
|
||||
borderColor: 'border-green-200 dark:border-green-800'
|
||||
},
|
||||
prediction: {
|
||||
color: 'info' as const,
|
||||
icon: BarChart3,
|
||||
bgColor: 'bg-blue-50 dark:bg-blue-900/20',
|
||||
borderColor: 'border-blue-200 dark:border-blue-800'
|
||||
},
|
||||
alert: {
|
||||
color: 'warning' as const,
|
||||
icon: AlertTriangle,
|
||||
bgColor: 'bg-orange-50 dark:bg-orange-900/20',
|
||||
borderColor: 'border-orange-200 dark:border-orange-800'
|
||||
},
|
||||
recommendation: {
|
||||
color: 'default' as const,
|
||||
icon: Lightbulb,
|
||||
bgColor: 'bg-purple-50 dark:bg-purple-900/20',
|
||||
borderColor: 'border-purple-200 dark:border-purple-800'
|
||||
}
|
||||
};
|
||||
return configs[type];
|
||||
};
|
||||
|
||||
const getCategoryIcon = (category: AIInsight['category']) => {
|
||||
const icons = {
|
||||
cost: DollarSign,
|
||||
quality: Target,
|
||||
efficiency: Zap,
|
||||
demand: BarChart3,
|
||||
maintenance: Users
|
||||
};
|
||||
return icons[category] || Package;
|
||||
};
|
||||
|
||||
const getImpactColor = (impact: AIInsight['impact']) => {
|
||||
const colors = {
|
||||
high: 'text-red-600',
|
||||
medium: 'text-orange-600',
|
||||
low: 'text-green-600'
|
||||
};
|
||||
return colors[impact];
|
||||
};
|
||||
|
||||
const formatCurrency = (amount: number) => {
|
||||
return new Intl.NumberFormat('es-ES', {
|
||||
style: 'currency',
|
||||
currency: 'EUR',
|
||||
minimumFractionDigits: 0
|
||||
}).format(amount);
|
||||
};
|
||||
|
||||
const highImpactInsights = aiInsights.filter(i => i.impact === 'high').length;
|
||||
const totalPotentialSavings = aiInsights.reduce((sum, i) => sum + (i.potentialSavings || 0), 0);
|
||||
const avgConfidence = aiInsights.reduce((sum, i) => sum + i.confidence, 0) / aiInsights.length;
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Card className={className}>
|
||||
<CardHeader>
|
||||
<h3 className="text-lg font-semibold text-[var(--text-primary)]">
|
||||
{t('ai.title', 'AI Insights')}
|
||||
</h3>
|
||||
</CardHeader>
|
||||
<CardBody>
|
||||
<div className="animate-pulse space-y-4">
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div className="h-12 bg-[var(--bg-secondary)] rounded"></div>
|
||||
<div className="h-12 bg-[var(--bg-secondary)] rounded"></div>
|
||||
<div className="h-12 bg-[var(--bg-secondary)] rounded"></div>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
<div className="h-20 bg-[var(--bg-secondary)] rounded"></div>
|
||||
<div className="h-20 bg-[var(--bg-secondary)] rounded"></div>
|
||||
</div>
|
||||
</div>
|
||||
</CardBody>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<Card className={className}>
|
||||
<CardBody className="text-center py-8">
|
||||
<Brain className="w-12 h-12 text-[var(--text-tertiary)] mx-auto mb-4" />
|
||||
<p className="text-[var(--text-secondary)]">
|
||||
{t('ai.error', 'AI insights temporarily unavailable')}
|
||||
</p>
|
||||
</CardBody>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className={className}>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Brain className="w-5 h-5 text-[var(--color-primary)]" />
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-[var(--text-primary)]">
|
||||
{t('ai.title', 'AI Insights')}
|
||||
</h3>
|
||||
<p className="text-sm text-[var(--text-secondary)]">
|
||||
{t('ai.subtitle', 'AI-powered recommendations and predictions')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={onViewAll}
|
||||
>
|
||||
{t('common.view_all', 'View All')}
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardBody className="space-y-6">
|
||||
{/* Summary Stats */}
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div className="text-center">
|
||||
<div className="text-2xl font-bold text-red-600">
|
||||
{highImpactInsights}
|
||||
</div>
|
||||
<div className="text-sm text-[var(--text-secondary)]">
|
||||
{t('ai.high_impact', 'High Impact')}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className="text-2xl font-bold text-green-600">
|
||||
{totalPotentialSavings > 0 ? formatCurrency(totalPotentialSavings) : ''}
|
||||
</div>
|
||||
<div className="text-sm text-[var(--text-secondary)]">
|
||||
{t('ai.potential_savings', 'Potential Savings')}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className="text-2xl font-bold text-[var(--color-primary)]">
|
||||
{avgConfidence.toFixed(0)}%
|
||||
</div>
|
||||
<div className="text-sm text-[var(--text-secondary)]">
|
||||
{t('ai.confidence', 'Avg Confidence')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Insights List */}
|
||||
<div className="space-y-3">
|
||||
{aiInsights.slice(0, 3).map((insight) => {
|
||||
const config = getInsightConfig(insight.type);
|
||||
const CategoryIcon = getCategoryIcon(insight.category);
|
||||
const TypeIcon = config.icon;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={insight.id}
|
||||
className={`p-4 rounded-lg border ${config.bgColor} ${config.borderColor} hover:shadow-sm transition-all cursor-pointer`}
|
||||
onClick={() => onViewInsight?.(insight.id)}
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-start space-x-3 flex-1">
|
||||
<div className="flex items-center space-x-1">
|
||||
<TypeIcon className="w-5 h-5 text-[var(--color-primary)]" />
|
||||
<CategoryIcon className="w-4 h-4 text-[var(--text-tertiary)]" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center space-x-2 mb-1">
|
||||
<h4 className="font-medium text-[var(--text-primary)] text-sm">
|
||||
{insight.title}
|
||||
</h4>
|
||||
<Badge variant={config.color} size="sm">
|
||||
{insight.confidence}%
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="text-sm text-[var(--text-secondary)] mb-2">
|
||||
{insight.description}
|
||||
</p>
|
||||
<div className="flex items-center space-x-4 text-xs text-[var(--text-tertiary)]">
|
||||
<span className={`font-medium ${getImpactColor(insight.impact)}`}>
|
||||
{insight.impact.toUpperCase()} {t('ai.impact', 'IMPACT')}
|
||||
</span>
|
||||
{insight.timeToImplement && (
|
||||
<span className="flex items-center space-x-1">
|
||||
<Clock className="w-3 h-3" />
|
||||
<span>{insight.timeToImplement}</span>
|
||||
</span>
|
||||
)}
|
||||
{insight.potentialSavings && (
|
||||
<span className="font-medium text-green-600">
|
||||
{formatCurrency(insight.potentialSavings)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{insight.data && (
|
||||
<div className="text-right ml-3">
|
||||
<div className="text-sm font-medium text-[var(--text-primary)]">
|
||||
{insight.data.current} <EFBFBD> {insight.data.predicted}
|
||||
</div>
|
||||
<div className="text-xs text-[var(--text-secondary)]">
|
||||
{insight.data.unit}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Action buttons */}
|
||||
<div className="flex items-center space-x-2 mt-3 pt-3 border-t border-[var(--border-primary)]">
|
||||
{insight.actions.slice(0, 2).map((action, index) => (
|
||||
<Button
|
||||
key={index}
|
||||
variant={index === 0 ? "primary" : "outline"}
|
||||
size="xs"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onImplement?.(insight.id, action.action);
|
||||
}}
|
||||
>
|
||||
{action.label}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* AI Status */}
|
||||
<div className="flex items-center justify-between pt-4 border-t border-[var(--border-primary)]">
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="w-2 h-2 bg-green-500 rounded-full animate-pulse"></div>
|
||||
<span className="text-sm text-[var(--text-secondary)]">
|
||||
{t('ai.status.active', 'AI monitoring active')}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-xs text-[var(--text-tertiary)]">
|
||||
{t('ai.last_updated', 'Updated 5m ago')}
|
||||
</div>
|
||||
</div>
|
||||
</CardBody>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default AIInsightsWidget;
|
||||
@@ -0,0 +1,392 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Card, CardHeader, CardBody } from '../../ui/Card';
|
||||
import { Badge } from '../../ui/Badge';
|
||||
import { Button } from '../../ui/Button';
|
||||
import {
|
||||
Settings,
|
||||
AlertTriangle,
|
||||
CheckCircle,
|
||||
Clock,
|
||||
Thermometer,
|
||||
Activity,
|
||||
Wrench,
|
||||
Zap,
|
||||
Calendar,
|
||||
TrendingUp
|
||||
} from 'lucide-react';
|
||||
import { useCurrentTenant } from '../../../stores/tenant.store';
|
||||
import { useProductionDashboard } from '../../../api';
|
||||
|
||||
export interface EquipmentStatus {
|
||||
id: string;
|
||||
name: string;
|
||||
type: 'oven' | 'mixer' | 'proofer' | 'freezer' | 'packaging';
|
||||
status: 'operational' | 'maintenance' | 'down' | 'warning';
|
||||
location: string;
|
||||
temperature?: number;
|
||||
targetTemperature?: number;
|
||||
efficiency: number;
|
||||
uptime: number;
|
||||
lastMaintenance: string;
|
||||
nextMaintenance: string;
|
||||
alerts: string[];
|
||||
energyUsage: number;
|
||||
utilizationToday: number;
|
||||
}
|
||||
|
||||
export interface EquipmentStatusWidgetProps {
|
||||
className?: string;
|
||||
equipment?: EquipmentStatus[];
|
||||
onViewEquipment?: (equipmentId: string) => void;
|
||||
onScheduleMaintenance?: (equipmentId: string) => void;
|
||||
onViewAll?: () => void;
|
||||
}
|
||||
|
||||
const EquipmentStatusWidget: React.FC<EquipmentStatusWidgetProps> = ({
|
||||
className,
|
||||
equipment,
|
||||
onViewEquipment,
|
||||
onScheduleMaintenance,
|
||||
onViewAll
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const currentTenant = useCurrentTenant();
|
||||
const tenantId = currentTenant?.id || '';
|
||||
|
||||
const {
|
||||
data: dashboardData,
|
||||
isLoading,
|
||||
error
|
||||
} = useProductionDashboard(tenantId);
|
||||
|
||||
const equipmentData = useMemo((): EquipmentStatus[] => {
|
||||
if (equipment) return equipment;
|
||||
|
||||
// Mock data for demonstration
|
||||
return [
|
||||
{
|
||||
id: '1',
|
||||
name: 'Horno Principal #1',
|
||||
type: 'oven',
|
||||
status: 'operational',
|
||||
location: '<27>rea de Horneado',
|
||||
temperature: 220,
|
||||
targetTemperature: 220,
|
||||
efficiency: 92,
|
||||
uptime: 98.5,
|
||||
lastMaintenance: '2024-01-15',
|
||||
nextMaintenance: '2024-03-15',
|
||||
alerts: [],
|
||||
energyUsage: 45.2,
|
||||
utilizationToday: 87
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
name: 'Batidora Industrial #2',
|
||||
type: 'mixer',
|
||||
status: 'warning',
|
||||
location: '<27>rea de Preparaci<63>n',
|
||||
efficiency: 88,
|
||||
uptime: 94.2,
|
||||
lastMaintenance: '2024-01-20',
|
||||
nextMaintenance: '2024-02-20',
|
||||
alerts: ['Vibraci<63>n inusual detectada', 'Revisar correas'],
|
||||
energyUsage: 12.8,
|
||||
utilizationToday: 76
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
name: 'C<>mara de Fermentaci<63>n #1',
|
||||
type: 'proofer',
|
||||
status: 'maintenance',
|
||||
location: '<27>rea de Fermentaci<63>n',
|
||||
temperature: 32,
|
||||
targetTemperature: 35,
|
||||
efficiency: 0,
|
||||
uptime: 85.1,
|
||||
lastMaintenance: '2024-01-23',
|
||||
nextMaintenance: '2024-01-24',
|
||||
alerts: ['En mantenimiento programado'],
|
||||
energyUsage: 0,
|
||||
utilizationToday: 0
|
||||
},
|
||||
{
|
||||
id: '4',
|
||||
name: 'Congelador R<>pido',
|
||||
type: 'freezer',
|
||||
status: 'operational',
|
||||
location: '<27>rea de Congelado',
|
||||
temperature: -18,
|
||||
targetTemperature: -18,
|
||||
efficiency: 95,
|
||||
uptime: 99.2,
|
||||
lastMaintenance: '2024-01-10',
|
||||
nextMaintenance: '2024-04-10',
|
||||
alerts: [],
|
||||
energyUsage: 23.5,
|
||||
utilizationToday: 65
|
||||
}
|
||||
];
|
||||
}, [equipment]);
|
||||
|
||||
const getStatusConfig = (status: EquipmentStatus['status']) => {
|
||||
const configs = {
|
||||
operational: {
|
||||
color: 'success' as const,
|
||||
icon: CheckCircle,
|
||||
label: t('equipment.status.operational', 'Operational')
|
||||
},
|
||||
warning: {
|
||||
color: 'warning' as const,
|
||||
icon: AlertTriangle,
|
||||
label: t('equipment.status.warning', 'Warning')
|
||||
},
|
||||
maintenance: {
|
||||
color: 'info' as const,
|
||||
icon: Wrench,
|
||||
label: t('equipment.status.maintenance', 'Maintenance')
|
||||
},
|
||||
down: {
|
||||
color: 'error' as const,
|
||||
icon: AlertTriangle,
|
||||
label: t('equipment.status.down', 'Down')
|
||||
}
|
||||
};
|
||||
return configs[status];
|
||||
};
|
||||
|
||||
const getEquipmentIcon = (type: EquipmentStatus['type']) => {
|
||||
const icons = {
|
||||
oven: Thermometer,
|
||||
mixer: Activity,
|
||||
proofer: Settings,
|
||||
freezer: Zap,
|
||||
packaging: Settings
|
||||
};
|
||||
return icons[type] || Settings;
|
||||
};
|
||||
|
||||
const summary = useMemo(() => {
|
||||
const total = equipmentData.length;
|
||||
const operational = equipmentData.filter(e => e.status === 'operational').length;
|
||||
const warning = equipmentData.filter(e => e.status === 'warning').length;
|
||||
const maintenance = equipmentData.filter(e => e.status === 'maintenance').length;
|
||||
const down = equipmentData.filter(e => e.status === 'down').length;
|
||||
const avgEfficiency = equipmentData.reduce((sum, e) => sum + e.efficiency, 0) / total;
|
||||
const avgUptime = equipmentData.reduce((sum, e) => sum + e.uptime, 0) / total;
|
||||
const totalEnergyUsage = equipmentData.reduce((sum, e) => sum + e.energyUsage, 0);
|
||||
|
||||
return {
|
||||
total,
|
||||
operational,
|
||||
warning,
|
||||
maintenance,
|
||||
down,
|
||||
avgEfficiency,
|
||||
avgUptime,
|
||||
totalEnergyUsage
|
||||
};
|
||||
}, [equipmentData]);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Card className={className}>
|
||||
<CardHeader>
|
||||
<h3 className="text-lg font-semibold text-[var(--text-primary)]">
|
||||
{t('equipment.title', 'Equipment Status')}
|
||||
</h3>
|
||||
</CardHeader>
|
||||
<CardBody>
|
||||
<div className="animate-pulse space-y-4">
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div className="h-12 bg-[var(--bg-secondary)] rounded"></div>
|
||||
<div className="h-12 bg-[var(--bg-secondary)] rounded"></div>
|
||||
<div className="h-12 bg-[var(--bg-secondary)] rounded"></div>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
<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}>
|
||||
<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('equipment.error', 'Error loading equipment data')}
|
||||
</p>
|
||||
</CardBody>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className={className}>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-[var(--text-primary)]">
|
||||
{t('equipment.title', 'Equipment Status')}
|
||||
</h3>
|
||||
<p className="text-sm text-[var(--text-secondary)]">
|
||||
{t('equipment.subtitle', 'Monitor equipment health and performance')}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={onViewAll}
|
||||
>
|
||||
{t('common.view_all', 'View All')}
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardBody className="space-y-6">
|
||||
{/* Summary Stats */}
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div className="text-center">
|
||||
<div className="text-2xl font-bold text-green-600">
|
||||
{summary.operational}
|
||||
</div>
|
||||
<div className="text-sm text-[var(--text-secondary)]">
|
||||
{t('equipment.operational', 'Operational')}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className="text-2xl font-bold text-orange-600">
|
||||
{summary.warning + summary.maintenance}
|
||||
</div>
|
||||
<div className="text-sm text-[var(--text-secondary)]">
|
||||
{t('equipment.needs_attention', 'Needs Attention')}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className="text-2xl font-bold text-[var(--color-primary)]">
|
||||
{summary.avgEfficiency.toFixed(1)}%
|
||||
</div>
|
||||
<div className="text-sm text-[var(--text-secondary)]">
|
||||
{t('equipment.efficiency', 'Efficiency')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Equipment List */}
|
||||
<div className="space-y-3">
|
||||
{equipmentData.slice(0, 4).map((item) => {
|
||||
const statusConfig = getStatusConfig(item.status);
|
||||
const EquipmentIcon = getEquipmentIcon(item.type);
|
||||
const StatusIcon = statusConfig.icon;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={item.id}
|
||||
className="flex items-center justify-between p-3 bg-[var(--bg-secondary)] rounded-lg hover:bg-[var(--bg-tertiary)] transition-colors cursor-pointer"
|
||||
onClick={() => onViewEquipment?.(item.id)}
|
||||
>
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="flex items-center space-x-2">
|
||||
<EquipmentIcon className="w-5 h-5 text-[var(--text-tertiary)]" />
|
||||
<StatusIcon className={`w-4 h-4 ${
|
||||
item.status === 'operational' ? 'text-green-500' :
|
||||
item.status === 'warning' ? 'text-orange-500' :
|
||||
item.status === 'maintenance' ? 'text-blue-500' :
|
||||
'text-red-500'
|
||||
}`} />
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-medium text-[var(--text-primary)] text-sm">
|
||||
{item.name}
|
||||
</div>
|
||||
<div className="text-xs text-[var(--text-secondary)]">
|
||||
{item.location}
|
||||
{item.temperature && (
|
||||
<span className="ml-2">
|
||||
{item.temperature}°C
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="text-right">
|
||||
<div className="text-sm font-medium text-[var(--text-primary)]">
|
||||
{item.efficiency}%
|
||||
</div>
|
||||
<div className="text-xs text-[var(--text-secondary)]">
|
||||
{t('equipment.efficiency', 'Efficiency')}
|
||||
</div>
|
||||
</div>
|
||||
<Badge variant={statusConfig.color}>
|
||||
{statusConfig.label}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Key Metrics */}
|
||||
<div className="grid grid-cols-2 gap-4 pt-4 border-t border-[var(--border-primary)]">
|
||||
<div className="text-center">
|
||||
<div className="flex items-center justify-center space-x-2 mb-1">
|
||||
<TrendingUp className="w-4 h-4 text-[var(--color-primary)]" />
|
||||
<span className="text-lg font-bold text-[var(--text-primary)]">
|
||||
{summary.avgUptime.toFixed(1)}%
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-sm text-[var(--text-secondary)]">
|
||||
{t('equipment.uptime', 'Average Uptime')}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className="flex items-center justify-center space-x-2 mb-1">
|
||||
<Zap className="w-4 h-4 text-yellow-500" />
|
||||
<span className="text-lg font-bold text-[var(--text-primary)]">
|
||||
{summary.totalEnergyUsage.toFixed(1)} kW
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-sm text-[var(--text-secondary)]">
|
||||
{t('equipment.energy_usage', 'Energy Usage')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Alerts */}
|
||||
{equipmentData.some(e => e.alerts.length > 0) && (
|
||||
<div className="space-y-2">
|
||||
<h4 className="text-sm font-medium text-[var(--text-primary)]">
|
||||
{t('equipment.alerts', 'Recent Alerts')}
|
||||
</h4>
|
||||
{equipmentData
|
||||
.filter(e => e.alerts.length > 0)
|
||||
.slice(0, 3)
|
||||
.map((item) => (
|
||||
<div key={item.id} className="flex items-start space-x-2 p-2 bg-orange-50 dark:bg-orange-900/20 rounded border-l-2 border-orange-500">
|
||||
<AlertTriangle className="w-4 h-4 text-orange-500 mt-0.5 flex-shrink-0" />
|
||||
<div>
|
||||
<div className="text-sm font-medium text-[var(--text-primary)]">
|
||||
{item.name}
|
||||
</div>
|
||||
<div className="text-xs text-[var(--text-secondary)]">
|
||||
{item.alerts[0]}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardBody>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default EquipmentStatusWidget;
|
||||
@@ -0,0 +1,322 @@
|
||||
import React, { useMemo } 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 {
|
||||
Euro,
|
||||
TrendingUp,
|
||||
TrendingDown,
|
||||
AlertTriangle,
|
||||
Target,
|
||||
Clock,
|
||||
Activity,
|
||||
Zap
|
||||
} 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;
|
||||
};
|
||||
}
|
||||
|
||||
export interface ProductionCostMonitorProps {
|
||||
className?: string;
|
||||
data?: ProductionCostData;
|
||||
onViewDetails?: () => void;
|
||||
onOptimize?: () => void;
|
||||
}
|
||||
|
||||
const ProductionCostMonitor: React.FC<ProductionCostMonitorProps> = ({
|
||||
className,
|
||||
data,
|
||||
onViewDetails,
|
||||
onOptimize
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const currentTenant = useCurrentTenant();
|
||||
const tenantId = currentTenant?.id || '';
|
||||
|
||||
const {
|
||||
data: dashboardData,
|
||||
isLoading,
|
||||
error
|
||||
} = useProductionDashboard(tenantId);
|
||||
|
||||
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
|
||||
}
|
||||
};
|
||||
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
|
||||
}
|
||||
};
|
||||
}, [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();
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Card className={className}>
|
||||
<CardHeader>
|
||||
<h3 className="text-lg font-semibold text-[var(--text-primary)]">
|
||||
{t('production.cost.title', 'Production Cost Monitor')}
|
||||
</h3>
|
||||
</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}>
|
||||
<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_yesterday', 'vs yesterday')
|
||||
},
|
||||
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_today', 'Average today')
|
||||
}
|
||||
];
|
||||
|
||||
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'
|
||||
}
|
||||
];
|
||||
|
||||
return (
|
||||
<Card className={className}>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-[var(--text-primary)]">
|
||||
{t('production.cost.title', 'Production Cost Monitor')}
|
||||
</h3>
|
||||
<p className="text-sm text-[var(--text-secondary)]">
|
||||
{t('production.cost.subtitle', 'Track and optimize production costs')}
|
||||
</p>
|
||||
</div>
|
||||
<Badge variant={budgetStatus.color as any}>
|
||||
{budgetStatus.label}
|
||||
</Badge>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardBody className="space-y-6">
|
||||
{/* Key Metrics */}
|
||||
<StatsGrid
|
||||
stats={costStats}
|
||||
columns={2}
|
||||
gap="md"
|
||||
/>
|
||||
|
||||
{/* Cost Breakdown */}
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-[var(--text-primary)] mb-4">
|
||||
{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">
|
||||
<div
|
||||
className={`h-2 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 space-x-3 pt-4 border-t border-[var(--border-primary)]">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={onViewDetails}
|
||||
className="flex-1"
|
||||
>
|
||||
{t('production.cost.view_details', 'View Details')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
size="sm"
|
||||
onClick={onOptimize}
|
||||
className="flex-1"
|
||||
>
|
||||
{t('production.cost.optimize', 'Optimize Costs')}
|
||||
</Button>
|
||||
</div>
|
||||
</CardBody>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProductionCostMonitor;
|
||||
@@ -5,9 +5,12 @@ export { default as RealTimeAlerts } from './RealTimeAlerts';
|
||||
export { default as ProcurementPlansToday } from './ProcurementPlansToday';
|
||||
export { default as ProductionPlansToday } from './ProductionPlansToday';
|
||||
|
||||
// Note: The following components are specified in the design but not yet implemented:
|
||||
// - DashboardCard
|
||||
// - DashboardGrid
|
||||
// - QuickActions
|
||||
// - RecentActivity
|
||||
// - KPIWidget
|
||||
// Production Management Dashboard Widgets
|
||||
export { default as ProductionCostMonitor } from './ProductionCostMonitor';
|
||||
export { default as EquipmentStatusWidget } from './EquipmentStatusWidget';
|
||||
export { default as AIInsightsWidget } from './AIInsightsWidget';
|
||||
|
||||
// Types
|
||||
export type { ProductionCostData } from './ProductionCostMonitor';
|
||||
export type { EquipmentStatus } from './EquipmentStatusWidget';
|
||||
export type { AIInsight } from './AIInsightsWidget';
|
||||
Reference in New Issue
Block a user