Add a new analitycs page for production
This commit is contained in:
@@ -56,11 +56,11 @@ export const useProductionDashboard = (
|
||||
export const useDailyProductionRequirements = (
|
||||
tenantId: string,
|
||||
date?: string,
|
||||
options?: Omit<UseQueryOptions<DailyProductionRequirements, ApiError>, 'queryKey' | 'queryFn'>
|
||||
options?: Omit<UseQueryOptions<any, ApiError>, 'queryKey' | 'queryFn'>
|
||||
) => {
|
||||
return useQuery<DailyProductionRequirements, ApiError>({
|
||||
return useQuery<any, ApiError>({
|
||||
queryKey: productionKeys.dailyRequirements(tenantId, date),
|
||||
queryFn: () => productionService.getDailyRequirements(tenantId, date),
|
||||
queryFn: () => productionService.getDailyProductionPlan(tenantId, date),
|
||||
enabled: !!tenantId,
|
||||
staleTime: 5 * 60 * 1000, // 5 minutes
|
||||
...options,
|
||||
@@ -70,11 +70,13 @@ export const useDailyProductionRequirements = (
|
||||
export const useProductionRequirements = (
|
||||
tenantId: string,
|
||||
date?: string,
|
||||
options?: Omit<UseQueryOptions<ProductionRequirements, ApiError>, 'queryKey' | 'queryFn'>
|
||||
options?: Omit<UseQueryOptions<any, ApiError>, 'queryKey' | 'queryFn'>
|
||||
) => {
|
||||
return useQuery<ProductionRequirements, ApiError>({
|
||||
const queryDate = date || new Date().toISOString().split('T')[0];
|
||||
|
||||
return useQuery<any, ApiError>({
|
||||
queryKey: productionKeys.requirements(tenantId, date),
|
||||
queryFn: () => productionService.getProductionRequirements(tenantId, date),
|
||||
queryFn: () => productionService.getProductionRequirements(tenantId, queryDate),
|
||||
enabled: !!tenantId,
|
||||
staleTime: 5 * 60 * 1000, // 5 minutes
|
||||
...options,
|
||||
@@ -117,11 +119,11 @@ export const useProductionSchedule = (
|
||||
tenantId: string,
|
||||
startDate?: string,
|
||||
endDate?: string,
|
||||
options?: Omit<UseQueryOptions<ProductionScheduleData, ApiError>, 'queryKey' | 'queryFn'>
|
||||
options?: Omit<UseQueryOptions<any, ApiError>, 'queryKey' | 'queryFn'>
|
||||
) => {
|
||||
return useQuery<ProductionScheduleData, ApiError>({
|
||||
return useQuery<any, ApiError>({
|
||||
queryKey: productionKeys.schedule(tenantId, startDate, endDate),
|
||||
queryFn: () => productionService.getProductionSchedule(tenantId, startDate, endDate),
|
||||
queryFn: () => productionService.getSchedules(tenantId, { start_date: startDate, end_date: endDate }),
|
||||
enabled: !!tenantId,
|
||||
staleTime: 5 * 60 * 1000, // 5 minutes
|
||||
...options,
|
||||
@@ -131,11 +133,11 @@ export const useProductionSchedule = (
|
||||
export const useCapacityStatus = (
|
||||
tenantId: string,
|
||||
date?: string,
|
||||
options?: Omit<UseQueryOptions<ProductionCapacityStatus, ApiError>, 'queryKey' | 'queryFn'>
|
||||
options?: Omit<UseQueryOptions<any, ApiError>, 'queryKey' | 'queryFn'>
|
||||
) => {
|
||||
return useQuery<ProductionCapacityStatus, ApiError>({
|
||||
return useQuery<any, ApiError>({
|
||||
queryKey: productionKeys.capacity(tenantId, date),
|
||||
queryFn: () => productionService.getCapacityStatus(tenantId, date),
|
||||
queryFn: () => date ? productionService.getCapacityByDate(tenantId, date) : productionService.getCapacity(tenantId),
|
||||
enabled: !!tenantId,
|
||||
staleTime: 5 * 60 * 1000, // 5 minutes
|
||||
...options,
|
||||
@@ -146,11 +148,11 @@ export const useYieldMetrics = (
|
||||
tenantId: string,
|
||||
startDate: string,
|
||||
endDate: string,
|
||||
options?: Omit<UseQueryOptions<ProductionYieldMetrics, ApiError>, 'queryKey' | 'queryFn'>
|
||||
options?: Omit<UseQueryOptions<any, ApiError>, 'queryKey' | 'queryFn'>
|
||||
) => {
|
||||
return useQuery<ProductionYieldMetrics, ApiError>({
|
||||
return useQuery<any, ApiError>({
|
||||
queryKey: productionKeys.yieldMetrics(tenantId, startDate, endDate),
|
||||
queryFn: () => productionService.getYieldMetrics(tenantId, startDate, endDate),
|
||||
queryFn: () => productionService.getYieldTrends(tenantId, startDate, endDate),
|
||||
enabled: !!tenantId && !!startDate && !!endDate,
|
||||
staleTime: 15 * 60 * 1000, // 15 minutes (metrics are less frequently changing)
|
||||
...options,
|
||||
|
||||
@@ -384,4 +384,76 @@ export interface BatchStatistics {
|
||||
on_time_rate: number;
|
||||
period_start: string;
|
||||
period_end: string;
|
||||
}
|
||||
|
||||
// Additional types needed for hooks
|
||||
export interface DailyProductionRequirements {
|
||||
date: string;
|
||||
total_planned_units: number;
|
||||
total_completed_units: number;
|
||||
products: Array<{
|
||||
product_id: string;
|
||||
product_name: string;
|
||||
planned_quantity: number;
|
||||
completed_quantity: number;
|
||||
required_materials: Array<{
|
||||
ingredient_id: string;
|
||||
ingredient_name: string;
|
||||
required_amount: number;
|
||||
unit: string;
|
||||
}>;
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface ProductionScheduleData {
|
||||
schedules: Array<{
|
||||
id: string;
|
||||
date: string;
|
||||
shift_start: string;
|
||||
shift_end: string;
|
||||
total_batches_planned: number;
|
||||
staff_count: number;
|
||||
utilization_percentage: number;
|
||||
is_active: boolean;
|
||||
is_finalized: boolean;
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface ProductionCapacityStatus {
|
||||
date: string;
|
||||
total_capacity: number;
|
||||
utilized_capacity: number;
|
||||
utilization_percentage: number;
|
||||
equipment_utilization: Array<{
|
||||
equipment_id: string;
|
||||
equipment_name: string;
|
||||
capacity: number;
|
||||
utilization: number;
|
||||
status: 'operational' | 'maintenance' | 'down';
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface ProductionRequirements {
|
||||
date: string;
|
||||
products: Array<{
|
||||
product_id: string;
|
||||
product_name: string;
|
||||
required_quantity: number;
|
||||
planned_quantity: number;
|
||||
priority: ProductionPriority;
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface ProductionYieldMetrics {
|
||||
start_date: string;
|
||||
end_date: string;
|
||||
overall_yield: number;
|
||||
products: Array<{
|
||||
product_id: string;
|
||||
product_name: string;
|
||||
average_yield: number;
|
||||
best_yield: number;
|
||||
worst_yield: number;
|
||||
batch_count: number;
|
||||
}>;
|
||||
}
|
||||
362
frontend/src/components/analytics/production/AnalyticsChart.tsx
Normal file
362
frontend/src/components/analytics/production/AnalyticsChart.tsx
Normal file
@@ -0,0 +1,362 @@
|
||||
import React, { useRef, useEffect } from 'react';
|
||||
|
||||
export type ChartType = 'line' | 'bar' | 'area' | 'pie' | 'doughnut';
|
||||
|
||||
export interface ChartDataPoint {
|
||||
x: string | number;
|
||||
y: number;
|
||||
label?: string;
|
||||
color?: string;
|
||||
}
|
||||
|
||||
export interface ChartSeries {
|
||||
id: string;
|
||||
name: string;
|
||||
type: ChartType;
|
||||
data: ChartDataPoint[];
|
||||
color: string;
|
||||
visible?: boolean;
|
||||
}
|
||||
|
||||
export interface AnalyticsChartProps {
|
||||
series: ChartSeries[];
|
||||
height?: number;
|
||||
className?: string;
|
||||
showLegend?: boolean;
|
||||
showGrid?: boolean;
|
||||
animate?: boolean;
|
||||
}
|
||||
|
||||
export const AnalyticsChart: React.FC<AnalyticsChartProps> = ({
|
||||
series,
|
||||
height = 300,
|
||||
className = '',
|
||||
showLegend = true,
|
||||
showGrid = true,
|
||||
animate = true
|
||||
}) => {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!canvasRef.current || series.length === 0) return;
|
||||
|
||||
const canvas = canvasRef.current;
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) return;
|
||||
|
||||
// Set canvas size
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
canvas.width = rect.width * dpr;
|
||||
canvas.height = rect.height * dpr;
|
||||
ctx.scale(dpr, dpr);
|
||||
|
||||
// Clear canvas
|
||||
ctx.clearRect(0, 0, rect.width, rect.height);
|
||||
|
||||
// Chart settings
|
||||
const padding = 40;
|
||||
const chartWidth = rect.width - 2 * padding;
|
||||
const chartHeight = rect.height - 2 * padding - (showLegend ? 40 : 0);
|
||||
|
||||
// Get visible series
|
||||
const visibleSeries = series.filter(s => s.visible !== false);
|
||||
if (visibleSeries.length === 0) return;
|
||||
|
||||
// Draw based on chart type
|
||||
const primarySeries = visibleSeries[0];
|
||||
|
||||
if (primarySeries.type === 'pie' || primarySeries.type === 'doughnut') {
|
||||
drawPieChart(ctx, primarySeries, rect.width, rect.height - (showLegend ? 40 : 0));
|
||||
} else {
|
||||
drawCartesianChart(ctx, visibleSeries, padding, chartWidth, chartHeight, showGrid);
|
||||
}
|
||||
|
||||
// Draw legend
|
||||
if (showLegend) {
|
||||
drawLegend(ctx, visibleSeries, rect.width, rect.height);
|
||||
}
|
||||
}, [series, height, showLegend, showGrid]);
|
||||
|
||||
const drawPieChart = (ctx: CanvasRenderingContext2D, series: ChartSeries, width: number, height: number) => {
|
||||
const centerX = width / 2;
|
||||
const centerY = height / 2;
|
||||
const radius = Math.min(width, height) / 3;
|
||||
const innerRadius = series.type === 'doughnut' ? radius * 0.6 : 0;
|
||||
|
||||
const total = series.data.reduce((sum, point) => sum + point.y, 0);
|
||||
let startAngle = -Math.PI / 2;
|
||||
|
||||
series.data.forEach((point, index) => {
|
||||
const sliceAngle = (point.y / total) * 2 * Math.PI;
|
||||
const color = point.color || series.color || getDefaultColor(index);
|
||||
|
||||
// Draw slice
|
||||
ctx.fillStyle = color;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(centerX, centerY);
|
||||
ctx.arc(centerX, centerY, radius, startAngle, startAngle + sliceAngle);
|
||||
if (innerRadius > 0) {
|
||||
ctx.arc(centerX, centerY, innerRadius, startAngle + sliceAngle, startAngle, true);
|
||||
}
|
||||
ctx.closePath();
|
||||
ctx.fill();
|
||||
|
||||
// Draw slice border
|
||||
ctx.strokeStyle = '#ffffff';
|
||||
ctx.lineWidth = 2;
|
||||
ctx.stroke();
|
||||
|
||||
// Draw labels
|
||||
const labelAngle = startAngle + sliceAngle / 2;
|
||||
const labelRadius = radius + 20;
|
||||
const labelX = centerX + Math.cos(labelAngle) * labelRadius;
|
||||
const labelY = centerY + Math.sin(labelAngle) * labelRadius;
|
||||
|
||||
ctx.fillStyle = '#374151';
|
||||
ctx.font = '12px sans-serif';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.fillText(point.label || point.x.toString(), labelX, labelY);
|
||||
|
||||
const percentage = ((point.y / total) * 100).toFixed(1);
|
||||
ctx.fillText(`${percentage}%`, labelX, labelY + 15);
|
||||
|
||||
startAngle += sliceAngle;
|
||||
});
|
||||
|
||||
// Draw center label for doughnut
|
||||
if (series.type === 'doughnut') {
|
||||
ctx.fillStyle = '#374151';
|
||||
ctx.font = 'bold 16px sans-serif';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.fillText(total.toString(), centerX, centerY - 5);
|
||||
ctx.font = '12px sans-serif';
|
||||
ctx.fillText('Total', centerX, centerY + 10);
|
||||
}
|
||||
};
|
||||
|
||||
const drawCartesianChart = (
|
||||
ctx: CanvasRenderingContext2D,
|
||||
seriesList: ChartSeries[],
|
||||
padding: number,
|
||||
chartWidth: number,
|
||||
chartHeight: number,
|
||||
showGrid: boolean
|
||||
) => {
|
||||
// Get all data points to determine scales
|
||||
const allData = seriesList.flatMap(s => s.data);
|
||||
const maxY = Math.max(...allData.map(d => d.y));
|
||||
const minY = Math.min(0, Math.min(...allData.map(d => d.y)));
|
||||
|
||||
// Draw grid
|
||||
if (showGrid) {
|
||||
ctx.strokeStyle = '#e2e8f0';
|
||||
ctx.lineWidth = 1;
|
||||
|
||||
// Horizontal grid lines
|
||||
for (let i = 0; i <= 5; i++) {
|
||||
const y = padding + (i * chartHeight) / 5;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(padding, y);
|
||||
ctx.lineTo(padding + chartWidth, y);
|
||||
ctx.stroke();
|
||||
|
||||
// Y-axis labels
|
||||
const value = maxY - (i * (maxY - minY)) / 5;
|
||||
ctx.fillStyle = '#64748b';
|
||||
ctx.font = '12px sans-serif';
|
||||
ctx.textAlign = 'right';
|
||||
ctx.fillText(Math.round(value).toString(), padding - 10, y + 4);
|
||||
}
|
||||
}
|
||||
|
||||
// Draw axes
|
||||
ctx.strokeStyle = '#374151';
|
||||
ctx.lineWidth = 2;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(padding, padding);
|
||||
ctx.lineTo(padding, padding + chartHeight);
|
||||
ctx.lineTo(padding + chartWidth, padding + chartHeight);
|
||||
ctx.stroke();
|
||||
|
||||
// Draw each series
|
||||
seriesList.forEach((series, seriesIndex) => {
|
||||
ctx.strokeStyle = series.color || getDefaultColor(seriesIndex);
|
||||
ctx.fillStyle = series.color || getDefaultColor(seriesIndex);
|
||||
|
||||
if (series.type === 'line') {
|
||||
drawLineChart(ctx, series, padding, chartWidth, chartHeight, maxY, minY);
|
||||
} else if (series.type === 'bar') {
|
||||
drawBarChart(ctx, series, padding, chartWidth, chartHeight, maxY, minY, seriesIndex, seriesList.length);
|
||||
} else if (series.type === 'area') {
|
||||
drawAreaChart(ctx, series, padding, chartWidth, chartHeight, maxY, minY);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const drawLineChart = (
|
||||
ctx: CanvasRenderingContext2D,
|
||||
series: ChartSeries,
|
||||
padding: number,
|
||||
chartWidth: number,
|
||||
chartHeight: number,
|
||||
maxY: number,
|
||||
minY: number
|
||||
) => {
|
||||
if (series.data.length === 0) return;
|
||||
|
||||
ctx.lineWidth = 3;
|
||||
ctx.beginPath();
|
||||
|
||||
series.data.forEach((point, index) => {
|
||||
const x = padding + (index * chartWidth) / (series.data.length - 1 || 1);
|
||||
const y = padding + chartHeight - ((point.y - minY) / (maxY - minY)) * chartHeight;
|
||||
|
||||
if (index === 0) {
|
||||
ctx.moveTo(x, y);
|
||||
} else {
|
||||
ctx.lineTo(x, y);
|
||||
}
|
||||
|
||||
// Draw points
|
||||
ctx.fillStyle = ctx.strokeStyle;
|
||||
ctx.beginPath();
|
||||
ctx.arc(x, y, 4, 0, 2 * Math.PI);
|
||||
ctx.fill();
|
||||
});
|
||||
|
||||
ctx.stroke();
|
||||
};
|
||||
|
||||
const drawBarChart = (
|
||||
ctx: CanvasRenderingContext2D,
|
||||
series: ChartSeries,
|
||||
padding: number,
|
||||
chartWidth: number,
|
||||
chartHeight: number,
|
||||
maxY: number,
|
||||
minY: number,
|
||||
seriesIndex: number,
|
||||
totalSeries: number
|
||||
) => {
|
||||
const barGroupWidth = chartWidth / series.data.length;
|
||||
const barWidth = barGroupWidth / totalSeries - 4;
|
||||
|
||||
series.data.forEach((point, index) => {
|
||||
const x = padding + index * barGroupWidth + seriesIndex * (barWidth + 2) + 2;
|
||||
const barHeight = ((point.y - minY) / (maxY - minY)) * chartHeight;
|
||||
const y = padding + chartHeight - barHeight;
|
||||
|
||||
ctx.fillRect(x, y, barWidth, barHeight);
|
||||
|
||||
// Draw value labels
|
||||
ctx.fillStyle = '#374151';
|
||||
ctx.font = '12px sans-serif';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.fillText(point.y.toString(), x + barWidth / 2, y - 5);
|
||||
|
||||
// Draw x-axis labels
|
||||
if (seriesIndex === 0) {
|
||||
ctx.fillText(
|
||||
point.label || point.x.toString(),
|
||||
padding + index * barGroupWidth + barGroupWidth / 2,
|
||||
padding + chartHeight + 20
|
||||
);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const drawAreaChart = (
|
||||
ctx: CanvasRenderingContext2D,
|
||||
series: ChartSeries,
|
||||
padding: number,
|
||||
chartWidth: number,
|
||||
chartHeight: number,
|
||||
maxY: number,
|
||||
minY: number
|
||||
) => {
|
||||
if (series.data.length === 0) return;
|
||||
|
||||
// Create gradient
|
||||
const gradient = ctx.createLinearGradient(0, padding, 0, padding + chartHeight);
|
||||
gradient.addColorStop(0, `${series.color}40`);
|
||||
gradient.addColorStop(1, `${series.color}10`);
|
||||
|
||||
ctx.fillStyle = gradient;
|
||||
ctx.strokeStyle = series.color;
|
||||
ctx.lineWidth = 2;
|
||||
|
||||
ctx.beginPath();
|
||||
|
||||
// Start from bottom left
|
||||
ctx.moveTo(padding, padding + chartHeight);
|
||||
|
||||
series.data.forEach((point, index) => {
|
||||
const x = padding + (index * chartWidth) / (series.data.length - 1 || 1);
|
||||
const y = padding + chartHeight - ((point.y - minY) / (maxY - minY)) * chartHeight;
|
||||
ctx.lineTo(x, y);
|
||||
});
|
||||
|
||||
// Close the path at bottom right
|
||||
ctx.lineTo(padding + chartWidth, padding + chartHeight);
|
||||
ctx.closePath();
|
||||
ctx.fill();
|
||||
|
||||
// Draw the line on top
|
||||
ctx.beginPath();
|
||||
series.data.forEach((point, index) => {
|
||||
const x = padding + (index * chartWidth) / (series.data.length - 1 || 1);
|
||||
const y = padding + chartHeight - ((point.y - minY) / (maxY - minY)) * chartHeight;
|
||||
|
||||
if (index === 0) {
|
||||
ctx.moveTo(x, y);
|
||||
} else {
|
||||
ctx.lineTo(x, y);
|
||||
}
|
||||
});
|
||||
ctx.stroke();
|
||||
};
|
||||
|
||||
const drawLegend = (ctx: CanvasRenderingContext2D, seriesList: ChartSeries[], width: number, height: number) => {
|
||||
const legendY = height - 30;
|
||||
let legendX = width / 2 - (seriesList.length * 80) / 2;
|
||||
|
||||
seriesList.forEach((series, index) => {
|
||||
// Draw legend color box
|
||||
ctx.fillStyle = series.color || getDefaultColor(index);
|
||||
ctx.fillRect(legendX, legendY, 12, 12);
|
||||
|
||||
// Draw legend text
|
||||
ctx.fillStyle = '#374151';
|
||||
ctx.font = '12px sans-serif';
|
||||
ctx.textAlign = 'left';
|
||||
ctx.fillText(series.name, legendX + 18, legendY + 9);
|
||||
|
||||
legendX += Math.max(80, ctx.measureText(series.name).width + 30);
|
||||
});
|
||||
};
|
||||
|
||||
const getDefaultColor = (index: number): string => {
|
||||
const colors = [
|
||||
'#d97706', // orange
|
||||
'#0284c7', // blue
|
||||
'#16a34a', // green
|
||||
'#dc2626', // red
|
||||
'#7c3aed', // purple
|
||||
'#0891b2', // cyan
|
||||
'#ea580c', // orange variant
|
||||
'#059669' // emerald
|
||||
];
|
||||
return colors[index % colors.length];
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`relative ${className}`} style={{ height: `${height}px` }}>
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
className="w-full h-full"
|
||||
style={{ height: `${height}px` }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
103
frontend/src/components/analytics/production/AnalyticsWidget.tsx
Normal file
103
frontend/src/components/analytics/production/AnalyticsWidget.tsx
Normal file
@@ -0,0 +1,103 @@
|
||||
import React from 'react';
|
||||
import { Card } from '../../ui';
|
||||
import { LucideIcon } from 'lucide-react';
|
||||
|
||||
export interface AnalyticsWidgetProps {
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
icon?: LucideIcon;
|
||||
loading?: boolean;
|
||||
error?: string;
|
||||
className?: string;
|
||||
children: React.ReactNode;
|
||||
actions?: React.ReactNode;
|
||||
}
|
||||
|
||||
export const AnalyticsWidget: React.FC<AnalyticsWidgetProps> = ({
|
||||
title,
|
||||
subtitle,
|
||||
icon: Icon,
|
||||
loading = false,
|
||||
error,
|
||||
className = '',
|
||||
children,
|
||||
actions
|
||||
}) => {
|
||||
if (error) {
|
||||
return (
|
||||
<Card className={`p-6 ${className}`}>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center space-x-3">
|
||||
{Icon && (
|
||||
<div className="w-10 h-10 bg-red-100 dark:bg-red-900/20 rounded-lg flex items-center justify-center">
|
||||
<Icon className="w-5 h-5 text-red-600 dark:text-red-400" />
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-[var(--text-primary)]">{title}</h3>
|
||||
{subtitle && <p className="text-sm text-[var(--text-secondary)]">{subtitle}</p>}
|
||||
</div>
|
||||
</div>
|
||||
{actions}
|
||||
</div>
|
||||
<div className="text-center py-8">
|
||||
<div className="w-12 h-12 mx-auto bg-red-100 dark:bg-red-900/20 rounded-lg flex items-center justify-center mb-3">
|
||||
{Icon && <Icon className="w-6 h-6 text-red-600 dark:text-red-400" />}
|
||||
</div>
|
||||
<p className="text-sm text-red-600 dark:text-red-400">Error: {error}</p>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Card className={`p-6 ${className}`}>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center space-x-3">
|
||||
{Icon && (
|
||||
<div className="w-10 h-10 bg-[var(--bg-tertiary)] rounded-lg flex items-center justify-center">
|
||||
<Icon className="w-5 h-5 text-[var(--text-tertiary)]" />
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-[var(--text-primary)]">{title}</h3>
|
||||
{subtitle && <p className="text-sm text-[var(--text-secondary)]">{subtitle}</p>}
|
||||
</div>
|
||||
</div>
|
||||
{actions}
|
||||
</div>
|
||||
<div className="animate-pulse">
|
||||
<div className="h-4 bg-[var(--bg-tertiary)] rounded w-3/4 mb-3"></div>
|
||||
<div className="h-4 bg-[var(--bg-tertiary)] rounded w-1/2 mb-3"></div>
|
||||
<div className="h-8 bg-[var(--bg-tertiary)] rounded w-full mb-4"></div>
|
||||
<div className="space-y-2">
|
||||
<div className="h-4 bg-[var(--bg-tertiary)] rounded"></div>
|
||||
<div className="h-4 bg-[var(--bg-tertiary)] rounded w-5/6"></div>
|
||||
<div className="h-4 bg-[var(--bg-tertiary)] rounded w-4/6"></div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className={`p-6 ${className}`}>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center space-x-3">
|
||||
{Icon && (
|
||||
<div className="w-10 h-10 bg-[var(--color-primary)]/10 rounded-lg flex items-center justify-center">
|
||||
<Icon className="w-5 h-5 text-[var(--color-primary)]" />
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-[var(--text-primary)]">{title}</h3>
|
||||
{subtitle && <p className="text-sm text-[var(--text-secondary)]">{subtitle}</p>}
|
||||
</div>
|
||||
</div>
|
||||
{actions}
|
||||
</div>
|
||||
{children}
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,382 @@
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Brain, TrendingUp, AlertTriangle, Target, Zap, DollarSign, Clock } from 'lucide-react';
|
||||
import { AnalyticsWidget } from '../AnalyticsWidget';
|
||||
import { Badge, Button } from '../../../ui';
|
||||
import { useCurrentTenant } from '../../../../stores/tenant.store';
|
||||
|
||||
interface AIInsight {
|
||||
id: string;
|
||||
type: 'optimization' | 'prediction' | 'anomaly' | 'recommendation';
|
||||
priority: 'low' | 'medium' | 'high' | 'critical';
|
||||
confidence: number; // percentage
|
||||
title: string;
|
||||
description: string;
|
||||
impact: {
|
||||
type: 'cost_savings' | 'efficiency_gain' | 'quality_improvement' | 'risk_mitigation';
|
||||
value: number;
|
||||
unit: 'euros' | 'percentage' | 'hours' | 'units';
|
||||
};
|
||||
actionable: boolean;
|
||||
category: 'production' | 'quality' | 'maintenance' | 'energy' | 'scheduling';
|
||||
equipment?: string;
|
||||
timeline: string;
|
||||
status: 'new' | 'acknowledged' | 'in_progress' | 'implemented' | 'dismissed';
|
||||
}
|
||||
|
||||
export const AIInsightsWidget: React.FC = () => {
|
||||
const { t } = useTranslation('production');
|
||||
const currentTenant = useCurrentTenant();
|
||||
|
||||
// Mock AI insights data - replace with real AI API call
|
||||
const aiInsights: AIInsight[] = [
|
||||
{
|
||||
id: '1',
|
||||
type: 'optimization',
|
||||
priority: 'high',
|
||||
confidence: 92,
|
||||
title: 'Optimización de Horarios de Horneado',
|
||||
description: 'Ajustar los horarios de horneado para aprovechar las tarifas eléctricas más bajas puede reducir los costos de energía en un 15%.',
|
||||
impact: {
|
||||
type: 'cost_savings',
|
||||
value: 45,
|
||||
unit: 'euros'
|
||||
},
|
||||
actionable: true,
|
||||
category: 'energy',
|
||||
equipment: 'Horno Principal',
|
||||
timeline: 'Esta semana',
|
||||
status: 'new'
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
type: 'prediction',
|
||||
priority: 'medium',
|
||||
confidence: 87,
|
||||
title: 'Aumento de Demanda de Croissants Predicho',
|
||||
description: 'Los datos meteorológicos y de eventos sugieren un aumento del 40% en la demanda de croissants este fin de semana.',
|
||||
impact: {
|
||||
type: 'efficiency_gain',
|
||||
value: 25,
|
||||
unit: 'percentage'
|
||||
},
|
||||
actionable: true,
|
||||
category: 'production',
|
||||
timeline: 'Este fin de semana',
|
||||
status: 'acknowledged'
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
type: 'anomaly',
|
||||
priority: 'critical',
|
||||
confidence: 96,
|
||||
title: 'Declive en Puntuación de Calidad Detectado',
|
||||
description: 'La calidad del pan ha disminuido un 8% en los últimos 3 días. Verificar los niveles de humedad de la harina.',
|
||||
impact: {
|
||||
type: 'quality_improvement',
|
||||
value: 12,
|
||||
unit: 'percentage'
|
||||
},
|
||||
actionable: true,
|
||||
category: 'quality',
|
||||
timeline: 'Inmediatamente',
|
||||
status: 'in_progress'
|
||||
},
|
||||
{
|
||||
id: '4',
|
||||
type: 'recommendation',
|
||||
priority: 'medium',
|
||||
confidence: 89,
|
||||
title: 'Mantenimiento Preventivo Recomendado',
|
||||
description: 'La Mezcladora #2 muestra patrones de desgaste temprano. Programar mantenimiento para prevenir averías.',
|
||||
impact: {
|
||||
type: 'risk_mitigation',
|
||||
value: 8,
|
||||
unit: 'hours'
|
||||
},
|
||||
actionable: true,
|
||||
category: 'maintenance',
|
||||
equipment: 'Mezcladora A',
|
||||
timeline: 'Próxima semana',
|
||||
status: 'new'
|
||||
},
|
||||
{
|
||||
id: '5',
|
||||
type: 'optimization',
|
||||
priority: 'low',
|
||||
confidence: 78,
|
||||
title: 'Optimización de Secuencia de Productos',
|
||||
description: 'Reordenar la secuencia de productos puede mejorar la eficiencia general en un 5%.',
|
||||
impact: {
|
||||
type: 'efficiency_gain',
|
||||
value: 5,
|
||||
unit: 'percentage'
|
||||
},
|
||||
actionable: true,
|
||||
category: 'scheduling',
|
||||
timeline: 'Próximo mes',
|
||||
status: 'dismissed'
|
||||
}
|
||||
];
|
||||
|
||||
const getTypeIcon = (type: AIInsight['type']) => {
|
||||
switch (type) {
|
||||
case 'optimization': return Target;
|
||||
case 'prediction': return TrendingUp;
|
||||
case 'anomaly': return AlertTriangle;
|
||||
case 'recommendation': return Brain;
|
||||
default: return Brain;
|
||||
}
|
||||
};
|
||||
|
||||
const getTypeColor = (type: AIInsight['type']) => {
|
||||
switch (type) {
|
||||
case 'optimization': return 'text-green-600';
|
||||
case 'prediction': return 'text-blue-600';
|
||||
case 'anomaly': return 'text-red-600';
|
||||
case 'recommendation': return 'text-purple-600';
|
||||
default: return 'text-gray-600';
|
||||
}
|
||||
};
|
||||
|
||||
const getPriorityBadgeVariant = (priority: AIInsight['priority']) => {
|
||||
switch (priority) {
|
||||
case 'critical': return 'error';
|
||||
case 'high': return 'warning';
|
||||
case 'medium': return 'info';
|
||||
case 'low': return 'default';
|
||||
default: return 'default';
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusBadgeVariant = (status: AIInsight['status']) => {
|
||||
switch (status) {
|
||||
case 'new': return 'warning';
|
||||
case 'acknowledged': return 'info';
|
||||
case 'in_progress': return 'info';
|
||||
case 'implemented': return 'success';
|
||||
case 'dismissed': return 'default';
|
||||
default: return 'default';
|
||||
}
|
||||
};
|
||||
|
||||
const getImpactIcon = (type: AIInsight['impact']['type']) => {
|
||||
switch (type) {
|
||||
case 'cost_savings': return DollarSign;
|
||||
case 'efficiency_gain': return Zap;
|
||||
case 'quality_improvement': return Target;
|
||||
case 'risk_mitigation': return Clock;
|
||||
default: return Target;
|
||||
}
|
||||
};
|
||||
|
||||
const formatImpactValue = (impact: AIInsight['impact']) => {
|
||||
switch (impact.unit) {
|
||||
case 'euros': return `€${impact.value}`;
|
||||
case 'percentage': return `${impact.value}%`;
|
||||
case 'hours': return `${impact.value}h`;
|
||||
case 'units': return `${impact.value} unidades`;
|
||||
default: return impact.value.toString();
|
||||
}
|
||||
};
|
||||
|
||||
const activeInsights = aiInsights.filter(i => i.status !== 'dismissed');
|
||||
const highPriorityInsights = activeInsights.filter(i => i.priority === 'high' || i.priority === 'critical');
|
||||
const implementedInsights = aiInsights.filter(i => i.status === 'implemented');
|
||||
const avgConfidence = activeInsights.reduce((sum, insight) => sum + insight.confidence, 0) / activeInsights.length;
|
||||
|
||||
const totalPotentialSavings = aiInsights
|
||||
.filter(i => i.impact.type === 'cost_savings' && i.status !== 'dismissed')
|
||||
.reduce((sum, insight) => sum + insight.impact.value, 0);
|
||||
|
||||
return (
|
||||
<AnalyticsWidget
|
||||
title={t('ai.title')}
|
||||
subtitle={t('ai.subtitle')}
|
||||
icon={Brain}
|
||||
actions={
|
||||
<div className="flex space-x-2">
|
||||
<Button variant="outline" size="sm">
|
||||
<Brain className="w-4 h-4 mr-1" />
|
||||
{t('ai.actions.train_model')}
|
||||
</Button>
|
||||
<Button variant="primary" size="sm">
|
||||
<Target className="w-4 h-4 mr-1" />
|
||||
{t('ai.actions.implement_all')}
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className="space-y-6">
|
||||
{/* AI Insights Overview Stats */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<div className="text-center p-4 bg-[var(--bg-secondary)] rounded-lg">
|
||||
<Brain className="w-8 h-8 mx-auto text-purple-600 mb-2" />
|
||||
<p className="text-2xl font-bold text-[var(--text-primary)]">{activeInsights.length}</p>
|
||||
<p className="text-sm text-[var(--text-secondary)]">{t('ai.stats.active_insights')}</p>
|
||||
</div>
|
||||
<div className="text-center p-4 bg-[var(--bg-secondary)] rounded-lg">
|
||||
<AlertTriangle className="w-8 h-8 mx-auto text-red-600 mb-2" />
|
||||
<p className="text-2xl font-bold text-[var(--text-primary)]">{highPriorityInsights.length}</p>
|
||||
<p className="text-sm text-[var(--text-secondary)]">{t('ai.stats.high_priority')}</p>
|
||||
</div>
|
||||
<div className="text-center p-4 bg-[var(--bg-secondary)] rounded-lg">
|
||||
<div className="w-8 h-8 mx-auto bg-green-100 dark:bg-green-900/20 rounded-full flex items-center justify-center mb-2">
|
||||
<span className="text-green-600 font-bold text-sm">€</span>
|
||||
</div>
|
||||
<p className="text-2xl font-bold text-[var(--text-primary)]">€{totalPotentialSavings}</p>
|
||||
<p className="text-sm text-[var(--text-secondary)]">{t('ai.stats.potential_savings')}</p>
|
||||
</div>
|
||||
<div className="text-center p-4 bg-[var(--bg-secondary)] rounded-lg">
|
||||
<div className="w-8 h-8 mx-auto bg-blue-100 dark:bg-blue-900/20 rounded-full flex items-center justify-center mb-2">
|
||||
<span className="text-blue-600 font-bold text-sm">%</span>
|
||||
</div>
|
||||
<p className="text-2xl font-bold text-[var(--text-primary)]">{avgConfidence.toFixed(0)}%</p>
|
||||
<p className="text-sm text-[var(--text-secondary)]">{t('ai.stats.avg_confidence')}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* AI Status */}
|
||||
<div className="p-4 bg-green-50 dark:bg-green-900/20 rounded-lg">
|
||||
<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 font-medium text-green-600">
|
||||
{t('ai.status.active')}
|
||||
</span>
|
||||
<span className="text-xs text-[var(--text-secondary)] ml-2">
|
||||
{t('ai.last_updated')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* High Priority Insights */}
|
||||
{highPriorityInsights.length > 0 && (
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-[var(--text-primary)] mb-3 flex items-center">
|
||||
<AlertTriangle className="w-4 h-4 mr-2 text-red-600" />
|
||||
{t('ai.high_priority_insights')} ({highPriorityInsights.length})
|
||||
</h4>
|
||||
<div className="space-y-3">
|
||||
{highPriorityInsights.slice(0, 3).map((insight) => {
|
||||
const TypeIcon = getTypeIcon(insight.type);
|
||||
const ImpactIcon = getImpactIcon(insight.impact.type);
|
||||
|
||||
return (
|
||||
<div key={insight.id} className="p-4 bg-[var(--bg-secondary)] rounded-lg border border-red-200 dark:border-red-800">
|
||||
<div className="flex items-start justify-between mb-3">
|
||||
<div className="flex items-start space-x-3">
|
||||
<TypeIcon className={`w-5 h-5 mt-1 ${getTypeColor(insight.type)}`} />
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center space-x-2 mb-1">
|
||||
<p className="font-medium text-[var(--text-primary)]">{insight.title}</p>
|
||||
<Badge variant={getPriorityBadgeVariant(insight.priority)}>
|
||||
{t(`ai.priority.${insight.priority}`)}
|
||||
</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-secondary)]">
|
||||
<span>{t('ai.confidence')}: {insight.confidence}%</span>
|
||||
<span>{t('ai.timeline')}: {insight.timeline}</span>
|
||||
{insight.equipment && <span>{t('ai.equipment')}: {insight.equipment}</span>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Badge variant={getStatusBadgeVariant(insight.status)}>
|
||||
{t(`ai.status.${insight.status}`)}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{/* Impact */}
|
||||
<div className="flex items-center justify-between p-3 bg-[var(--bg-tertiary)] rounded-lg">
|
||||
<div className="flex items-center space-x-2">
|
||||
<ImpactIcon className="w-4 h-4 text-[var(--color-primary)]" />
|
||||
<span className="text-sm font-medium text-[var(--text-primary)]">
|
||||
{t(`ai.impact.${insight.impact.type}`)}
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-lg font-bold text-green-600">
|
||||
{formatImpactValue(insight.impact)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{insight.actionable && insight.status === 'new' && (
|
||||
<div className="mt-3 flex space-x-2">
|
||||
<Button variant="outline" size="sm">
|
||||
{t('ai.actions.acknowledge')}
|
||||
</Button>
|
||||
<Button variant="primary" size="sm">
|
||||
{t('ai.actions.implement')}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* All Insights */}
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-[var(--text-primary)] mb-3 flex items-center">
|
||||
<Brain className="w-4 h-4 mr-2" />
|
||||
{t('ai.all_insights')} ({activeInsights.length})
|
||||
</h4>
|
||||
<div className="space-y-3 max-h-96 overflow-y-auto">
|
||||
{activeInsights.map((insight) => {
|
||||
const TypeIcon = getTypeIcon(insight.type);
|
||||
const ImpactIcon = getImpactIcon(insight.impact.type);
|
||||
|
||||
return (
|
||||
<div key={insight.id} className="p-3 bg-[var(--bg-secondary)] rounded-lg hover:bg-[var(--bg-tertiary)] transition-colors">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-3">
|
||||
<TypeIcon className={`w-4 h-4 ${getTypeColor(insight.type)}`} />
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center space-x-2">
|
||||
<p className="font-medium text-[var(--text-primary)] text-sm">{insight.title}</p>
|
||||
<Badge variant={getPriorityBadgeVariant(insight.priority)} className="text-xs">
|
||||
{t(`ai.priority.${insight.priority}`)}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="flex items-center space-x-4 text-xs text-[var(--text-secondary)] mt-1">
|
||||
<span>{insight.confidence}% {t('ai.confidence')}</span>
|
||||
<span>•</span>
|
||||
<span>{insight.timeline}</span>
|
||||
<span>•</span>
|
||||
<div className="flex items-center space-x-1">
|
||||
<ImpactIcon className="w-3 h-3" />
|
||||
<span>{formatImpactValue(insight.impact)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Badge variant={getStatusBadgeVariant(insight.status)} className="text-xs">
|
||||
{t(`ai.status.${insight.status}`)}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* AI Performance Summary */}
|
||||
<div className="p-4 bg-blue-50 dark:bg-blue-900/20 rounded-lg">
|
||||
<div className="flex items-start space-x-2">
|
||||
<Brain className="w-5 h-5 text-blue-600 mt-0.5" />
|
||||
<div>
|
||||
<p className="text-sm font-medium text-blue-600">
|
||||
{t('ai.performance.summary')}
|
||||
</p>
|
||||
<p className="text-xs text-[var(--text-secondary)] mt-1">
|
||||
{implementedInsights.length} {t('ai.performance.insights_implemented')},
|
||||
{totalPotentialSavings > 0 && ` €${totalPotentialSavings} ${t('ai.performance.in_savings_identified')}`}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</AnalyticsWidget>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,253 @@
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { BarChart3, TrendingUp, AlertTriangle, Zap } from 'lucide-react';
|
||||
import { AnalyticsWidget } from '../AnalyticsWidget';
|
||||
import { AnalyticsChart, ChartSeries } from '../AnalyticsChart';
|
||||
import { Button, ProgressBar } from '../../../ui';
|
||||
import { useProductionDashboard, useCapacityStatus } from '../../../../api/hooks/production';
|
||||
import { useCurrentTenant } from '../../../../stores/tenant.store';
|
||||
|
||||
interface CapacityData {
|
||||
resource: string;
|
||||
utilization: number;
|
||||
capacity: number;
|
||||
allocated: number;
|
||||
available: number;
|
||||
type: 'oven' | 'mixer' | 'staff' | 'station';
|
||||
}
|
||||
|
||||
export const CapacityUtilizationWidget: React.FC = () => {
|
||||
const { t } = useTranslation('production');
|
||||
const currentTenant = useCurrentTenant();
|
||||
const tenantId = currentTenant?.id || '';
|
||||
const today = new Date().toISOString().split('T')[0];
|
||||
|
||||
const { data: dashboard, isLoading: dashboardLoading, error: dashboardError } = useProductionDashboard(tenantId);
|
||||
const { data: capacity, isLoading: capacityLoading, error: capacityError } = useCapacityStatus(tenantId, today);
|
||||
|
||||
const isLoading = dashboardLoading || capacityLoading;
|
||||
const error = dashboardError?.message || capacityError?.message;
|
||||
|
||||
const overallUtilization = dashboard?.capacity_utilization || 0;
|
||||
|
||||
// Mock capacity data by resource type (in real implementation, this would be processed from capacity API)
|
||||
const getCapacityByResource = (): CapacityData[] => {
|
||||
return [
|
||||
{
|
||||
resource: t('equipment.oven_capacity'),
|
||||
utilization: 89,
|
||||
capacity: 24,
|
||||
allocated: 21,
|
||||
available: 3,
|
||||
type: 'oven'
|
||||
},
|
||||
{
|
||||
resource: t('equipment.mixer_capacity'),
|
||||
utilization: 67,
|
||||
capacity: 12,
|
||||
allocated: 8,
|
||||
available: 4,
|
||||
type: 'mixer'
|
||||
},
|
||||
{
|
||||
resource: t('schedule.staff_capacity'),
|
||||
utilization: 85,
|
||||
capacity: 8,
|
||||
allocated: 7,
|
||||
available: 1,
|
||||
type: 'staff'
|
||||
},
|
||||
{
|
||||
resource: t('schedule.work_stations'),
|
||||
utilization: 75,
|
||||
capacity: 6,
|
||||
allocated: 4,
|
||||
available: 2,
|
||||
type: 'station'
|
||||
}
|
||||
];
|
||||
};
|
||||
|
||||
const capacityData = getCapacityByResource();
|
||||
|
||||
// Heatmap data for hourly utilization
|
||||
const getHourlyUtilizationData = (): ChartSeries[] => {
|
||||
const hours = Array.from({ length: 12 }, (_, i) => i + 6); // 6 AM to 6 PM
|
||||
const mockData = hours.map(hour => ({
|
||||
x: `${hour}:00`,
|
||||
y: Math.max(20, Math.min(100, 50 + Math.sin(hour / 2) * 30 + Math.random() * 20)),
|
||||
label: `${hour}:00`
|
||||
}));
|
||||
|
||||
return [
|
||||
{
|
||||
id: 'hourly-utilization',
|
||||
name: t('insights.hourly_utilization'),
|
||||
type: 'area',
|
||||
color: '#d97706',
|
||||
data: mockData
|
||||
}
|
||||
];
|
||||
};
|
||||
|
||||
const getUtilizationStatus = (utilization: number) => {
|
||||
if (utilization >= 95) return { color: 'text-red-600', status: 'critical', bgColor: 'bg-red-100 dark:bg-red-900/20' };
|
||||
if (utilization >= 85) return { color: 'text-orange-600', status: 'high', bgColor: 'bg-orange-100 dark:bg-orange-900/20' };
|
||||
if (utilization >= 70) return { color: 'text-green-600', status: 'optimal', bgColor: 'bg-green-100 dark:bg-green-900/20' };
|
||||
return { color: 'text-blue-600', status: 'low', bgColor: 'bg-blue-100 dark:bg-blue-900/20' };
|
||||
};
|
||||
|
||||
const getResourceIcon = (type: CapacityData['type']) => {
|
||||
switch (type) {
|
||||
case 'oven': return '🔥';
|
||||
case 'mixer': return '🥄';
|
||||
case 'staff': return '👥';
|
||||
case 'station': return '🏭';
|
||||
default: return '📊';
|
||||
}
|
||||
};
|
||||
|
||||
const getProgressBarVariant = (utilization: number) => {
|
||||
if (utilization >= 95) return 'error';
|
||||
if (utilization >= 85) return 'warning';
|
||||
if (utilization >= 70) return 'success';
|
||||
return 'info';
|
||||
};
|
||||
|
||||
const overallStatus = getUtilizationStatus(overallUtilization);
|
||||
|
||||
return (
|
||||
<AnalyticsWidget
|
||||
title={t('stats.capacity_utilization')}
|
||||
subtitle={t('insights.resource_allocation_efficiency')}
|
||||
icon={BarChart3}
|
||||
loading={isLoading}
|
||||
error={error}
|
||||
actions={
|
||||
<Button variant="outline" size="sm">
|
||||
<Zap className="w-4 h-4 mr-1" />
|
||||
{t('actions.optimize')}
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
{/* Overall Utilization */}
|
||||
<div className="text-center">
|
||||
<div className="flex items-center justify-center space-x-2 mb-2">
|
||||
<BarChart3 className={`w-6 h-6 ${overallStatus.color}`} />
|
||||
<span className="text-3xl font-bold text-[var(--text-primary)]">
|
||||
{overallUtilization.toFixed(1)}%
|
||||
</span>
|
||||
</div>
|
||||
<div className={`inline-flex items-center space-x-2 px-3 py-1 rounded-full text-sm ${overallStatus.bgColor}`}>
|
||||
<span className={overallStatus.color}>
|
||||
{t(`status.${overallStatus.status}`)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Resource Breakdown */}
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-[var(--text-primary)] mb-3">
|
||||
{t('insights.resource_breakdown')}
|
||||
</h4>
|
||||
<div className="space-y-3">
|
||||
{capacityData.map((resource, index) => {
|
||||
const status = getUtilizationStatus(resource.utilization);
|
||||
|
||||
return (
|
||||
<div key={index} className="p-3 bg-[var(--bg-secondary)] rounded-lg">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="flex items-center space-x-2">
|
||||
<span className="text-lg">{getResourceIcon(resource.type)}</span>
|
||||
<span className="text-sm font-medium text-[var(--text-primary)]">
|
||||
{resource.resource}
|
||||
</span>
|
||||
</div>
|
||||
<span className={`text-sm font-semibold ${status.color}`}>
|
||||
{resource.utilization}%
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<ProgressBar
|
||||
value={resource.utilization}
|
||||
variant={getProgressBarVariant(resource.utilization)}
|
||||
size="sm"
|
||||
className="mb-2"
|
||||
/>
|
||||
|
||||
<div className="flex items-center justify-between text-xs text-[var(--text-secondary)]">
|
||||
<span>
|
||||
{resource.allocated}/{resource.capacity} {t('common.allocated')}
|
||||
</span>
|
||||
<span>
|
||||
{resource.available} {t('common.available')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Hourly Heatmap */}
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-[var(--text-primary)] mb-3">
|
||||
{t('insights.hourly_utilization_pattern')}
|
||||
</h4>
|
||||
<AnalyticsChart
|
||||
series={getHourlyUtilizationData()}
|
||||
height={120}
|
||||
showLegend={false}
|
||||
showGrid={true}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Alerts & Recommendations */}
|
||||
<div className="space-y-2">
|
||||
{capacityData.some(r => r.utilization >= 95) && (
|
||||
<div className="flex items-start space-x-2 p-3 bg-red-50 dark:bg-red-900/20 rounded-lg">
|
||||
<AlertTriangle className="w-4 h-4 mt-0.5 text-red-600" />
|
||||
<div>
|
||||
<p className="text-sm font-medium text-red-600">
|
||||
{t('alerts.capacity_critical')}
|
||||
</p>
|
||||
<p className="text-xs text-[var(--text-secondary)] mt-1">
|
||||
{t('alerts.capacity_critical_description')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{capacityData.some(r => r.utilization >= 85 && r.utilization < 95) && (
|
||||
<div className="flex items-start space-x-2 p-3 bg-orange-50 dark:bg-orange-900/20 rounded-lg">
|
||||
<TrendingUp className="w-4 h-4 mt-0.5 text-orange-600" />
|
||||
<div>
|
||||
<p className="text-sm font-medium text-orange-600">
|
||||
{t('alerts.capacity_high')}
|
||||
</p>
|
||||
<p className="text-xs text-[var(--text-secondary)] mt-1">
|
||||
{t('alerts.capacity_high_description')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Peak Hours Summary */}
|
||||
<div className="pt-3 border-t border-[var(--border-primary)]">
|
||||
<div className="grid grid-cols-2 gap-3 text-center text-sm">
|
||||
<div>
|
||||
<p className="font-semibold text-[var(--text-primary)]">10:00 - 12:00</p>
|
||||
<p className="text-xs text-[var(--text-secondary)]">{t('insights.peak_hours')}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-semibold text-[var(--text-primary)]">14:00 - 16:00</p>
|
||||
<p className="text-xs text-[var(--text-secondary)]">{t('insights.off_peak_optimal')}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</AnalyticsWidget>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,296 @@
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { DollarSign, TrendingUp, TrendingDown, PieChart } from 'lucide-react';
|
||||
import { AnalyticsWidget } from '../AnalyticsWidget';
|
||||
import { AnalyticsChart, ChartSeries } from '../AnalyticsChart';
|
||||
import { Button } from '../../../ui';
|
||||
import { useActiveBatches } from '../../../../api/hooks/production';
|
||||
import { useCurrentTenant } from '../../../../stores/tenant.store';
|
||||
|
||||
interface ProductCostData {
|
||||
product: string;
|
||||
estimatedCost: number;
|
||||
actualCost: number;
|
||||
variance: number;
|
||||
variancePercent: number;
|
||||
units: number;
|
||||
costPerUnit: number;
|
||||
}
|
||||
|
||||
export const CostPerUnitWidget: React.FC = () => {
|
||||
const { t } = useTranslation('production');
|
||||
const currentTenant = useCurrentTenant();
|
||||
const tenantId = currentTenant?.id || '';
|
||||
|
||||
const { data: batchesData, isLoading, error } = useActiveBatches(tenantId);
|
||||
const batches = batchesData?.batches || [];
|
||||
|
||||
// Process batches to calculate cost per unit data
|
||||
const getProductCostData = (): ProductCostData[] => {
|
||||
const productMap = new Map<string, any>();
|
||||
|
||||
batches.forEach(batch => {
|
||||
const key = batch.product_name;
|
||||
if (!productMap.has(key)) {
|
||||
productMap.set(key, {
|
||||
product: batch.product_name,
|
||||
estimatedCosts: [],
|
||||
actualCosts: [],
|
||||
quantities: []
|
||||
});
|
||||
}
|
||||
|
||||
const data = productMap.get(key);
|
||||
if (batch.estimated_cost) data.estimatedCosts.push(batch.estimated_cost);
|
||||
if (batch.actual_cost) data.actualCosts.push(batch.actual_cost);
|
||||
if (batch.actual_quantity || batch.planned_quantity) {
|
||||
data.quantities.push(batch.actual_quantity || batch.planned_quantity);
|
||||
}
|
||||
});
|
||||
|
||||
return Array.from(productMap.values()).map(data => {
|
||||
const avgEstimated = data.estimatedCosts.reduce((a: number, b: number) => a + b, 0) / (data.estimatedCosts.length || 1);
|
||||
const avgActual = data.actualCosts.reduce((a: number, b: number) => a + b, 0) / (data.actualCosts.length || 1);
|
||||
const totalUnits = data.quantities.reduce((a: number, b: number) => a + b, 0);
|
||||
const variance = avgActual - avgEstimated;
|
||||
const variancePercent = avgEstimated > 0 ? (variance / avgEstimated) * 100 : 0;
|
||||
const costPerUnit = totalUnits > 0 ? avgActual / totalUnits : avgActual;
|
||||
|
||||
return {
|
||||
product: data.product,
|
||||
estimatedCost: avgEstimated,
|
||||
actualCost: avgActual,
|
||||
variance,
|
||||
variancePercent,
|
||||
units: totalUnits,
|
||||
costPerUnit
|
||||
};
|
||||
}).filter(item => item.actualCost > 0);
|
||||
};
|
||||
|
||||
const productCostData = getProductCostData();
|
||||
const totalCosts = productCostData.reduce((sum, item) => sum + item.actualCost, 0);
|
||||
const averageCostPerUnit = productCostData.length > 0
|
||||
? productCostData.reduce((sum, item) => sum + item.costPerUnit, 0) / productCostData.length
|
||||
: 0;
|
||||
|
||||
// Create chart data for cost comparison
|
||||
const getCostComparisonChartData = (): ChartSeries[] => {
|
||||
if (productCostData.length === 0) return [];
|
||||
|
||||
const estimatedData = productCostData.map(item => ({
|
||||
x: item.product,
|
||||
y: item.estimatedCost,
|
||||
label: item.product
|
||||
}));
|
||||
|
||||
const actualData = productCostData.map(item => ({
|
||||
x: item.product,
|
||||
y: item.actualCost,
|
||||
label: item.product
|
||||
}));
|
||||
|
||||
return [
|
||||
{
|
||||
id: 'estimated-cost',
|
||||
name: t('cost.estimated_cost'),
|
||||
type: 'bar',
|
||||
color: '#94a3b8',
|
||||
data: estimatedData
|
||||
},
|
||||
{
|
||||
id: 'actual-cost',
|
||||
name: t('cost.actual_cost'),
|
||||
type: 'bar',
|
||||
color: '#d97706',
|
||||
data: actualData
|
||||
}
|
||||
];
|
||||
};
|
||||
|
||||
// Create pie chart for cost distribution
|
||||
const getCostDistributionChartData = (): ChartSeries[] => {
|
||||
if (productCostData.length === 0) return [];
|
||||
|
||||
const pieData = productCostData.map((item, index) => ({
|
||||
x: item.product,
|
||||
y: item.actualCost,
|
||||
label: item.product,
|
||||
color: getProductColor(index)
|
||||
}));
|
||||
|
||||
return [
|
||||
{
|
||||
id: 'cost-distribution',
|
||||
name: t('cost.cost_distribution'),
|
||||
type: 'pie',
|
||||
color: '#d97706',
|
||||
data: pieData
|
||||
}
|
||||
];
|
||||
};
|
||||
|
||||
const getProductColor = (index: number): string => {
|
||||
const colors = ['#d97706', '#0284c7', '#16a34a', '#dc2626', '#7c3aed', '#0891b2'];
|
||||
return colors[index % colors.length];
|
||||
};
|
||||
|
||||
const getVarianceStatus = (variancePercent: number) => {
|
||||
if (Math.abs(variancePercent) <= 5) return { color: 'text-green-600', status: 'on_target' };
|
||||
if (variancePercent > 5) return { color: 'text-red-600', status: 'over_budget' };
|
||||
return { color: 'text-blue-600', status: 'under_budget' };
|
||||
};
|
||||
|
||||
return (
|
||||
<AnalyticsWidget
|
||||
title={t('cost.cost_per_unit_analysis')}
|
||||
subtitle={t('cost.estimated_vs_actual_costs')}
|
||||
icon={DollarSign}
|
||||
loading={isLoading}
|
||||
error={error?.message}
|
||||
actions={
|
||||
<Button variant="outline" size="sm">
|
||||
<PieChart className="w-4 h-4 mr-1" />
|
||||
{t('cost.view_breakdown')}
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
{/* Overall Metrics */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="p-4 bg-[var(--bg-secondary)] rounded-lg text-center">
|
||||
<div className="flex items-center justify-center space-x-2 mb-2">
|
||||
<DollarSign className="w-5 h-5 text-[var(--color-primary)]" />
|
||||
<span className="text-lg font-bold text-[var(--text-primary)]">
|
||||
€{averageCostPerUnit.toFixed(2)}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm text-[var(--text-secondary)]">{t('cost.average_cost_per_unit')}</p>
|
||||
</div>
|
||||
<div className="p-4 bg-[var(--bg-secondary)] rounded-lg text-center">
|
||||
<div className="flex items-center justify-center space-x-2 mb-2">
|
||||
<TrendingUp className="w-5 h-5 text-[var(--color-primary)]" />
|
||||
<span className="text-lg font-bold text-[var(--text-primary)]">
|
||||
€{totalCosts.toFixed(0)}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm text-[var(--text-secondary)]">{t('cost.total_production_cost')}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{productCostData.length === 0 ? (
|
||||
<div className="text-center py-8 text-[var(--text-secondary)]">
|
||||
<DollarSign className="w-12 h-12 mx-auto mb-3 opacity-50" />
|
||||
<p className="text-sm">{t('cost.no_cost_data_available')}</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Cost Comparison Chart */}
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-[var(--text-primary)] mb-3">
|
||||
{t('cost.estimated_vs_actual')}
|
||||
</h4>
|
||||
<AnalyticsChart
|
||||
series={getCostComparisonChartData()}
|
||||
height={200}
|
||||
showLegend={true}
|
||||
showGrid={true}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Product Cost Details */}
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-[var(--text-primary)] mb-3">
|
||||
{t('cost.product_cost_breakdown')}
|
||||
</h4>
|
||||
<div className="space-y-3 max-h-64 overflow-y-auto">
|
||||
{productCostData.map((item, index) => {
|
||||
const varianceStatus = getVarianceStatus(item.variancePercent);
|
||||
const VarianceIcon = item.variance >= 0 ? TrendingUp : TrendingDown;
|
||||
|
||||
return (
|
||||
<div key={index} className="p-3 bg-[var(--bg-secondary)] rounded-lg">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="flex items-center space-x-3">
|
||||
<div
|
||||
className="w-3 h-3 rounded-full"
|
||||
style={{ backgroundColor: getProductColor(index) }}
|
||||
/>
|
||||
<span className="font-medium text-[var(--text-primary)]">
|
||||
{item.product}
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-lg font-bold text-[var(--text-primary)]">
|
||||
€{item.costPerUnit.toFixed(2)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-3 gap-3 text-sm">
|
||||
<div>
|
||||
<p className="text-[var(--text-secondary)]">{t('cost.estimated')}</p>
|
||||
<p className="font-semibold text-[var(--text-primary)]">
|
||||
€{item.estimatedCost.toFixed(2)}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-[var(--text-secondary)]">{t('cost.actual')}</p>
|
||||
<p className="font-semibold text-[var(--text-primary)]">
|
||||
€{item.actualCost.toFixed(2)}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-[var(--text-secondary)]">{t('cost.variance')}</p>
|
||||
<div className={`flex items-center space-x-1 ${varianceStatus.color}`}>
|
||||
<VarianceIcon className="w-3 h-3" />
|
||||
<span className="font-semibold">
|
||||
{item.variancePercent > 0 ? '+' : ''}{item.variancePercent.toFixed(1)}%
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-2 pt-2 border-t border-[var(--border-primary)] text-xs text-[var(--text-tertiary)]">
|
||||
{t('cost.units_produced')}: {item.units}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Cost Distribution Pie Chart */}
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-[var(--text-primary)] mb-3">
|
||||
{t('cost.cost_distribution')}
|
||||
</h4>
|
||||
<AnalyticsChart
|
||||
series={getCostDistributionChartData()}
|
||||
height={200}
|
||||
showLegend={true}
|
||||
showGrid={false}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Cost Optimization Insights */}
|
||||
<div className="p-3 bg-blue-50 dark:bg-blue-900/20 rounded-lg">
|
||||
<div className="flex items-start space-x-2">
|
||||
<DollarSign className="w-4 h-4 mt-0.5 text-blue-600" />
|
||||
<div>
|
||||
<p className="text-sm font-medium text-blue-600">
|
||||
{t('cost.optimization_opportunity')}
|
||||
</p>
|
||||
<p className="text-xs text-[var(--text-secondary)] mt-1">
|
||||
{productCostData.some(item => item.variancePercent > 10)
|
||||
? t('cost.high_variance_detected')
|
||||
: t('cost.costs_within_expected_range')
|
||||
}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</AnalyticsWidget>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,389 @@
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Zap, TrendingUp, TrendingDown, BarChart3, Target } from 'lucide-react';
|
||||
import { AnalyticsWidget } from '../AnalyticsWidget';
|
||||
import { AnalyticsChart, ChartSeries } from '../AnalyticsChart';
|
||||
import { Button } from '../../../ui';
|
||||
import { useCurrentTenant } from '../../../../stores/tenant.store';
|
||||
|
||||
interface EquipmentEfficiencyData {
|
||||
equipmentId: string;
|
||||
equipmentName: string;
|
||||
type: 'oven' | 'mixer' | 'proofer' | 'packaging';
|
||||
currentEfficiency: number;
|
||||
targetEfficiency: number;
|
||||
trend: 'up' | 'down' | 'stable';
|
||||
energyConsumption: number; // kWh
|
||||
productionOutput: number; // units per hour
|
||||
oee: number; // Overall Equipment Effectiveness
|
||||
availability: number;
|
||||
performance: number;
|
||||
quality: number;
|
||||
downtimeMinutes: number;
|
||||
weeklyData: Array<{
|
||||
day: string;
|
||||
efficiency: number;
|
||||
energyConsumption: number;
|
||||
output: number;
|
||||
}>;
|
||||
}
|
||||
|
||||
export const EquipmentEfficiencyWidget: React.FC = () => {
|
||||
const { t } = useTranslation('production');
|
||||
const currentTenant = useCurrentTenant();
|
||||
|
||||
// Mock efficiency data - replace with real API call
|
||||
const efficiencyData: EquipmentEfficiencyData[] = [
|
||||
{
|
||||
equipmentId: '1',
|
||||
equipmentName: 'Horno Principal',
|
||||
type: 'oven',
|
||||
currentEfficiency: 94,
|
||||
targetEfficiency: 95,
|
||||
trend: 'up',
|
||||
energyConsumption: 45.2,
|
||||
productionOutput: 120,
|
||||
oee: 89,
|
||||
availability: 95,
|
||||
performance: 96,
|
||||
quality: 98,
|
||||
downtimeMinutes: 15,
|
||||
weeklyData: [
|
||||
{ day: 'Lun', efficiency: 92, energyConsumption: 44, output: 118 },
|
||||
{ day: 'Mar', efficiency: 93, energyConsumption: 45, output: 119 },
|
||||
{ day: 'Mié', efficiency: 94, energyConsumption: 45.2, output: 120 },
|
||||
{ day: 'Jue', efficiency: 94, energyConsumption: 45.1, output: 121 },
|
||||
{ day: 'Vie', efficiency: 95, energyConsumption: 45.3, output: 122 },
|
||||
{ day: 'Sáb', efficiency: 94, energyConsumption: 44.8, output: 119 },
|
||||
{ day: 'Dom', efficiency: 93, energyConsumption: 44.5, output: 117 }
|
||||
]
|
||||
},
|
||||
{
|
||||
equipmentId: '2',
|
||||
equipmentName: 'Mezcladora A',
|
||||
type: 'mixer',
|
||||
currentEfficiency: 87,
|
||||
targetEfficiency: 90,
|
||||
trend: 'down',
|
||||
energyConsumption: 12.5,
|
||||
productionOutput: 85,
|
||||
oee: 82,
|
||||
availability: 92,
|
||||
performance: 89,
|
||||
quality: 95,
|
||||
downtimeMinutes: 45,
|
||||
weeklyData: [
|
||||
{ day: 'Lun', efficiency: 89, energyConsumption: 12, output: 88 },
|
||||
{ day: 'Mar', efficiency: 88, energyConsumption: 12.2, output: 87 },
|
||||
{ day: 'Mié', efficiency: 87, energyConsumption: 12.5, output: 85 },
|
||||
{ day: 'Jue', efficiency: 86, energyConsumption: 12.8, output: 84 },
|
||||
{ day: 'Vie', efficiency: 87, energyConsumption: 12.6, output: 86 },
|
||||
{ day: 'Sáb', efficiency: 88, energyConsumption: 12.3, output: 87 },
|
||||
{ day: 'Dom', efficiency: 87, energyConsumption: 12.4, output: 85 }
|
||||
]
|
||||
},
|
||||
{
|
||||
equipmentId: '3',
|
||||
equipmentName: 'Cámara de Fermentación 1',
|
||||
type: 'proofer',
|
||||
currentEfficiency: 96,
|
||||
targetEfficiency: 95,
|
||||
trend: 'stable',
|
||||
energyConsumption: 8.3,
|
||||
productionOutput: 95,
|
||||
oee: 94,
|
||||
availability: 98,
|
||||
performance: 97,
|
||||
quality: 99,
|
||||
downtimeMinutes: 5,
|
||||
weeklyData: [
|
||||
{ day: 'Lun', efficiency: 96, energyConsumption: 8.2, output: 94 },
|
||||
{ day: 'Mar', efficiency: 96, energyConsumption: 8.3, output: 95 },
|
||||
{ day: 'Mié', efficiency: 96, energyConsumption: 8.3, output: 95 },
|
||||
{ day: 'Jue', efficiency: 97, energyConsumption: 8.4, output: 96 },
|
||||
{ day: 'Vie', efficiency: 96, energyConsumption: 8.3, output: 95 },
|
||||
{ day: 'Sáb', efficiency: 95, energyConsumption: 8.2, output: 94 },
|
||||
{ day: 'Dom', efficiency: 96, energyConsumption: 8.3, output: 95 }
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
const getTrendIcon = (trend: EquipmentEfficiencyData['trend']) => {
|
||||
switch (trend) {
|
||||
case 'up': return TrendingUp;
|
||||
case 'down': return TrendingDown;
|
||||
case 'stable': return BarChart3;
|
||||
default: return BarChart3;
|
||||
}
|
||||
};
|
||||
|
||||
const getTrendColor = (trend: EquipmentEfficiencyData['trend']) => {
|
||||
switch (trend) {
|
||||
case 'up': return 'text-green-600';
|
||||
case 'down': return 'text-red-600';
|
||||
case 'stable': return 'text-blue-600';
|
||||
default: return 'text-gray-600';
|
||||
}
|
||||
};
|
||||
|
||||
const getEfficiencyStatus = (current: number, target: number) => {
|
||||
const percentage = (current / target) * 100;
|
||||
if (percentage >= 100) return { status: 'excellent', color: 'text-green-600', bgColor: 'bg-green-100 dark:bg-green-900/20' };
|
||||
if (percentage >= 90) return { status: 'good', color: 'text-blue-600', bgColor: 'bg-blue-100 dark:bg-blue-900/20' };
|
||||
if (percentage >= 80) return { status: 'warning', color: 'text-orange-600', bgColor: 'bg-orange-100 dark:bg-orange-900/20' };
|
||||
return { status: 'critical', color: 'text-red-600', bgColor: 'bg-red-100 dark:bg-red-900/20' };
|
||||
};
|
||||
|
||||
// Calculate overall metrics
|
||||
const avgEfficiency = efficiencyData.reduce((sum, item) => sum + item.currentEfficiency, 0) / efficiencyData.length;
|
||||
const avgOEE = efficiencyData.reduce((sum, item) => sum + item.oee, 0) / efficiencyData.length;
|
||||
const totalEnergyConsumption = efficiencyData.reduce((sum, item) => sum + item.energyConsumption, 0);
|
||||
const totalDowntime = efficiencyData.reduce((sum, item) => sum + item.downtimeMinutes, 0);
|
||||
|
||||
// Create efficiency comparison chart
|
||||
const getEfficiencyComparisonChartData = (): ChartSeries[] => {
|
||||
const currentData = efficiencyData.map(item => ({
|
||||
x: item.equipmentName,
|
||||
y: item.currentEfficiency,
|
||||
label: `${item.equipmentName}: ${item.currentEfficiency}%`
|
||||
}));
|
||||
|
||||
const targetData = efficiencyData.map(item => ({
|
||||
x: item.equipmentName,
|
||||
y: item.targetEfficiency,
|
||||
label: `Target: ${item.targetEfficiency}%`
|
||||
}));
|
||||
|
||||
return [
|
||||
{
|
||||
id: 'current-efficiency',
|
||||
name: t('equipment.efficiency.current'),
|
||||
type: 'bar',
|
||||
color: '#16a34a',
|
||||
data: currentData
|
||||
},
|
||||
{
|
||||
id: 'target-efficiency',
|
||||
name: t('equipment.efficiency.target'),
|
||||
type: 'bar',
|
||||
color: '#d97706',
|
||||
data: targetData
|
||||
}
|
||||
];
|
||||
};
|
||||
|
||||
// Create weekly efficiency trend chart
|
||||
const getWeeklyTrendChartData = (): ChartSeries[] => {
|
||||
return efficiencyData.map((equipment, index) => ({
|
||||
id: `weekly-${equipment.equipmentId}`,
|
||||
name: equipment.equipmentName,
|
||||
type: 'line',
|
||||
color: ['#16a34a', '#3b82f6', '#f59e0b'][index % 3],
|
||||
data: equipment.weeklyData.map(day => ({
|
||||
x: day.day,
|
||||
y: day.efficiency,
|
||||
label: `${day.day}: ${day.efficiency}%`
|
||||
}))
|
||||
}));
|
||||
};
|
||||
|
||||
// Create OEE breakdown chart
|
||||
const getOEEBreakdownChartData = (): ChartSeries[] => {
|
||||
const avgAvailability = efficiencyData.reduce((sum, item) => sum + item.availability, 0) / efficiencyData.length;
|
||||
const avgPerformance = efficiencyData.reduce((sum, item) => sum + item.performance, 0) / efficiencyData.length;
|
||||
const avgQuality = efficiencyData.reduce((sum, item) => sum + item.quality, 0) / efficiencyData.length;
|
||||
|
||||
return [
|
||||
{
|
||||
id: 'oee-breakdown',
|
||||
name: 'OEE Components',
|
||||
type: 'doughnut',
|
||||
color: '#3b82f6',
|
||||
data: [
|
||||
{ x: t('equipment.oee.availability'), y: avgAvailability, label: `${t('equipment.oee.availability')}: ${avgAvailability.toFixed(1)}%` },
|
||||
{ x: t('equipment.oee.performance'), y: avgPerformance, label: `${t('equipment.oee.performance')}: ${avgPerformance.toFixed(1)}%` },
|
||||
{ x: t('equipment.oee.quality'), y: avgQuality, label: `${t('equipment.oee.quality')}: ${avgQuality.toFixed(1)}%` }
|
||||
]
|
||||
}
|
||||
];
|
||||
};
|
||||
|
||||
return (
|
||||
<AnalyticsWidget
|
||||
title={t('equipment.efficiency.title')}
|
||||
subtitle={t('equipment.efficiency.subtitle')}
|
||||
icon={Zap}
|
||||
actions={
|
||||
<div className="flex space-x-2">
|
||||
<Button variant="outline" size="sm">
|
||||
<BarChart3 className="w-4 h-4 mr-1" />
|
||||
{t('equipment.efficiency.analyze')}
|
||||
</Button>
|
||||
<Button variant="primary" size="sm">
|
||||
<Target className="w-4 h-4 mr-1" />
|
||||
{t('equipment.efficiency.optimize')}
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className="space-y-6">
|
||||
{/* Efficiency Overview Stats */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<div className="text-center p-4 bg-[var(--bg-secondary)] rounded-lg">
|
||||
<Zap className="w-8 h-8 mx-auto text-green-600 mb-2" />
|
||||
<p className="text-2xl font-bold text-[var(--text-primary)]">{avgEfficiency.toFixed(1)}%</p>
|
||||
<p className="text-sm text-[var(--text-secondary)]">{t('equipment.efficiency.average')}</p>
|
||||
</div>
|
||||
<div className="text-center p-4 bg-[var(--bg-secondary)] rounded-lg">
|
||||
<Target className="w-8 h-8 mx-auto text-blue-600 mb-2" />
|
||||
<p className="text-2xl font-bold text-[var(--text-primary)]">{avgOEE.toFixed(1)}%</p>
|
||||
<p className="text-sm text-[var(--text-secondary)]">{t('equipment.oee.overall')}</p>
|
||||
</div>
|
||||
<div className="text-center p-4 bg-[var(--bg-secondary)] rounded-lg">
|
||||
<div className="w-8 h-8 mx-auto bg-orange-100 dark:bg-orange-900/20 rounded-full flex items-center justify-center mb-2">
|
||||
<span className="text-orange-600 font-bold text-sm">kWh</span>
|
||||
</div>
|
||||
<p className="text-2xl font-bold text-[var(--text-primary)]">{totalEnergyConsumption.toFixed(1)}</p>
|
||||
<p className="text-sm text-[var(--text-secondary)]">{t('equipment.energy_consumption')}</p>
|
||||
</div>
|
||||
<div className="text-center p-4 bg-[var(--bg-secondary)] rounded-lg">
|
||||
<div className="w-8 h-8 mx-auto bg-red-100 dark:bg-red-900/20 rounded-full flex items-center justify-center mb-2">
|
||||
<span className="text-red-600 font-bold text-xs">min</span>
|
||||
</div>
|
||||
<p className="text-2xl font-bold text-[var(--text-primary)]">{totalDowntime}</p>
|
||||
<p className="text-sm text-[var(--text-secondary)]">{t('equipment.downtime.total')}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Equipment Efficiency List */}
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-[var(--text-primary)] mb-3 flex items-center">
|
||||
<Zap className="w-4 h-4 mr-2" />
|
||||
{t('equipment.efficiency.by_equipment')}
|
||||
</h4>
|
||||
<div className="space-y-3">
|
||||
{efficiencyData.map((item) => {
|
||||
const TrendIcon = getTrendIcon(item.trend);
|
||||
const trendColor = getTrendColor(item.trend);
|
||||
const status = getEfficiencyStatus(item.currentEfficiency, item.targetEfficiency);
|
||||
|
||||
return (
|
||||
<div key={item.equipmentId} className="p-4 bg-[var(--bg-secondary)] rounded-lg">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="flex items-center space-x-3">
|
||||
<Zap className="w-5 h-5 text-[var(--color-primary)]" />
|
||||
<div>
|
||||
<p className="font-medium text-[var(--text-primary)]">{item.equipmentName}</p>
|
||||
<div className="flex items-center space-x-2 text-xs text-[var(--text-secondary)]">
|
||||
<span>OEE: {item.oee}%</span>
|
||||
<span>•</span>
|
||||
<span>{item.energyConsumption} kWh</span>
|
||||
<span>•</span>
|
||||
<span>{item.productionOutput} {t('equipment.units_per_hour')}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<TrendIcon className={`w-4 h-4 ${trendColor}`} />
|
||||
<div className="text-right">
|
||||
<div className="text-lg font-bold text-[var(--text-primary)]">
|
||||
{item.currentEfficiency}%
|
||||
</div>
|
||||
<div className="text-xs text-[var(--text-secondary)]">
|
||||
{t('equipment.efficiency.target')}: {item.targetEfficiency}%
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Progress bar */}
|
||||
<div className="w-full bg-[var(--bg-tertiary)] rounded-full h-2 mb-3">
|
||||
<div
|
||||
className="bg-[var(--color-primary)] h-2 rounded-full transition-all duration-300"
|
||||
style={{ width: `${(item.currentEfficiency / 100) * 100}%` }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* OEE Components */}
|
||||
<div className="grid grid-cols-3 gap-4 text-center">
|
||||
<div>
|
||||
<p className="text-lg font-semibold text-green-600">{item.availability}%</p>
|
||||
<p className="text-xs text-[var(--text-secondary)]">{t('equipment.oee.availability')}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-lg font-semibold text-blue-600">{item.performance}%</p>
|
||||
<p className="text-xs text-[var(--text-secondary)]">{t('equipment.oee.performance')}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-lg font-semibold text-orange-600">{item.quality}%</p>
|
||||
<p className="text-xs text-[var(--text-secondary)]">{t('equipment.oee.quality')}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Efficiency Charts */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{/* Current vs Target Efficiency */}
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-[var(--text-primary)] mb-3">
|
||||
{t('equipment.efficiency.current_vs_target')}
|
||||
</h4>
|
||||
<AnalyticsChart
|
||||
series={getEfficiencyComparisonChartData()}
|
||||
height={200}
|
||||
showLegend={true}
|
||||
showGrid={true}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* OEE Breakdown */}
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-[var(--text-primary)] mb-3">
|
||||
{t('equipment.oee.breakdown')}
|
||||
</h4>
|
||||
<AnalyticsChart
|
||||
series={getOEEBreakdownChartData()}
|
||||
height={200}
|
||||
showLegend={true}
|
||||
showGrid={false}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Weekly Efficiency Trends */}
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-[var(--text-primary)] mb-3">
|
||||
{t('equipment.efficiency.weekly_trends')}
|
||||
</h4>
|
||||
<AnalyticsChart
|
||||
series={getWeeklyTrendChartData()}
|
||||
height={250}
|
||||
showLegend={true}
|
||||
showGrid={true}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Efficiency Recommendations */}
|
||||
<div className="p-4 bg-blue-50 dark:bg-blue-900/20 rounded-lg">
|
||||
<div className="flex items-start space-x-2">
|
||||
<Target className="w-5 h-5 text-blue-600 mt-0.5" />
|
||||
<div>
|
||||
<p className="text-sm font-medium text-blue-600">
|
||||
{t('equipment.efficiency.recommendations')}
|
||||
</p>
|
||||
<ul className="text-xs text-[var(--text-secondary)] mt-1 space-y-1">
|
||||
<li>• {t('equipment.efficiency.recommendation_1')}: Mezcladora A {t('equipment.efficiency.needs_maintenance')}</li>
|
||||
<li>• {t('equipment.efficiency.recommendation_2')}: {t('equipment.efficiency.optimize_energy_consumption')}</li>
|
||||
<li>• {t('equipment.efficiency.recommendation_3')}: {t('equipment.efficiency.schedule_preventive_maintenance')}</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</AnalyticsWidget>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,285 @@
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Settings, AlertTriangle, CheckCircle, Clock, Zap } from 'lucide-react';
|
||||
import { AnalyticsWidget } from '../AnalyticsWidget';
|
||||
import { AnalyticsChart, ChartSeries } from '../AnalyticsChart';
|
||||
import { Badge, Button } from '../../../ui';
|
||||
import { useCurrentTenant } from '../../../../stores/tenant.store';
|
||||
|
||||
interface EquipmentStatus {
|
||||
id: string;
|
||||
name: string;
|
||||
type: 'oven' | 'mixer' | 'proofer' | 'packaging';
|
||||
status: 'operational' | 'warning' | 'maintenance' | 'down';
|
||||
efficiency: number;
|
||||
temperature?: number;
|
||||
uptime: number;
|
||||
lastMaintenance: string;
|
||||
nextMaintenance: string;
|
||||
alertCount: number;
|
||||
}
|
||||
|
||||
export const EquipmentStatusWidget: React.FC = () => {
|
||||
const { t } = useTranslation('production');
|
||||
const currentTenant = useCurrentTenant();
|
||||
|
||||
// Mock equipment data - replace with real API call
|
||||
const equipment: EquipmentStatus[] = [
|
||||
{
|
||||
id: '1',
|
||||
name: 'Horno Principal',
|
||||
type: 'oven',
|
||||
status: 'operational',
|
||||
efficiency: 94,
|
||||
temperature: 180,
|
||||
uptime: 98.5,
|
||||
lastMaintenance: '2024-01-15',
|
||||
nextMaintenance: '2024-02-15',
|
||||
alertCount: 0
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
name: 'Mezcladora A',
|
||||
type: 'mixer',
|
||||
status: 'warning',
|
||||
efficiency: 87,
|
||||
uptime: 85.2,
|
||||
lastMaintenance: '2024-01-10',
|
||||
nextMaintenance: '2024-02-10',
|
||||
alertCount: 2
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
name: 'Cámara de Fermentación 1',
|
||||
type: 'proofer',
|
||||
status: 'operational',
|
||||
efficiency: 96,
|
||||
temperature: 28,
|
||||
uptime: 99.1,
|
||||
lastMaintenance: '2024-01-20',
|
||||
nextMaintenance: '2024-02-20',
|
||||
alertCount: 0
|
||||
},
|
||||
{
|
||||
id: '4',
|
||||
name: 'Empaquetadora',
|
||||
type: 'packaging',
|
||||
status: 'maintenance',
|
||||
efficiency: 0,
|
||||
uptime: 0,
|
||||
lastMaintenance: '2024-01-25',
|
||||
nextMaintenance: '2024-01-26',
|
||||
alertCount: 1
|
||||
}
|
||||
];
|
||||
|
||||
const getStatusColor = (status: EquipmentStatus['status']) => {
|
||||
switch (status) {
|
||||
case 'operational': return 'text-green-600';
|
||||
case 'warning': return 'text-orange-600';
|
||||
case 'maintenance': return 'text-blue-600';
|
||||
case 'down': return 'text-red-600';
|
||||
default: return 'text-gray-600';
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusBadgeVariant = (status: EquipmentStatus['status']) => {
|
||||
switch (status) {
|
||||
case 'operational': return 'success';
|
||||
case 'warning': return 'warning';
|
||||
case 'maintenance': return 'info';
|
||||
case 'down': return 'error';
|
||||
default: return 'default';
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusIcon = (status: EquipmentStatus['status']) => {
|
||||
switch (status) {
|
||||
case 'operational': return CheckCircle;
|
||||
case 'warning': return AlertTriangle;
|
||||
case 'maintenance': return Clock;
|
||||
case 'down': return AlertTriangle;
|
||||
default: return Settings;
|
||||
}
|
||||
};
|
||||
|
||||
const operationalCount = equipment.filter(e => e.status === 'operational').length;
|
||||
const warningCount = equipment.filter(e => e.status === 'warning').length;
|
||||
const maintenanceCount = equipment.filter(e => e.status === 'maintenance').length;
|
||||
const downCount = equipment.filter(e => e.status === 'down').length;
|
||||
const avgEfficiency = equipment.reduce((sum, e) => sum + e.efficiency, 0) / equipment.length;
|
||||
|
||||
// Equipment efficiency chart data
|
||||
const getEfficiencyChartData = (): ChartSeries[] => {
|
||||
const data = equipment.map(item => ({
|
||||
x: item.name,
|
||||
y: item.efficiency,
|
||||
label: `${item.name}: ${item.efficiency}%`
|
||||
}));
|
||||
|
||||
return [
|
||||
{
|
||||
id: 'equipment-efficiency',
|
||||
name: t('equipment.efficiency'),
|
||||
type: 'bar',
|
||||
color: '#16a34a',
|
||||
data
|
||||
}
|
||||
];
|
||||
};
|
||||
|
||||
// Status distribution chart
|
||||
const getStatusDistributionChartData = (): ChartSeries[] => {
|
||||
const statusData = [
|
||||
{ x: t('equipment.status.operational'), y: operationalCount, label: `${operationalCount} ${t('equipment.status.operational')}` },
|
||||
{ x: t('equipment.status.warning'), y: warningCount, label: `${warningCount} ${t('equipment.status.warning')}` },
|
||||
{ x: t('equipment.status.maintenance'), y: maintenanceCount, label: `${maintenanceCount} ${t('equipment.status.maintenance')}` },
|
||||
{ x: t('equipment.status.down'), y: downCount, label: `${downCount} ${t('equipment.status.down')}` }
|
||||
].filter(item => item.y > 0);
|
||||
|
||||
return [
|
||||
{
|
||||
id: 'status-distribution',
|
||||
name: t('equipment.status.distribution'),
|
||||
type: 'doughnut',
|
||||
color: '#3b82f6',
|
||||
data: statusData
|
||||
}
|
||||
];
|
||||
};
|
||||
|
||||
return (
|
||||
<AnalyticsWidget
|
||||
title={t('equipment.title')}
|
||||
subtitle={t('equipment.subtitle')}
|
||||
icon={Settings}
|
||||
actions={
|
||||
<div className="flex space-x-2">
|
||||
<Button variant="outline" size="sm">
|
||||
<Clock className="w-4 h-4 mr-1" />
|
||||
{t('equipment.actions.schedule_maintenance')}
|
||||
</Button>
|
||||
<Button variant="primary" size="sm">
|
||||
<Zap className="w-4 h-4 mr-1" />
|
||||
{t('actions.optimize')}
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className="space-y-6">
|
||||
{/* Equipment Overview Stats */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<div className="text-center p-4 bg-[var(--bg-secondary)] rounded-lg">
|
||||
<CheckCircle className="w-8 h-8 mx-auto text-green-600 mb-2" />
|
||||
<p className="text-2xl font-bold text-[var(--text-primary)]">{operationalCount}</p>
|
||||
<p className="text-sm text-[var(--text-secondary)]">{t('equipment.stats.operational')}</p>
|
||||
</div>
|
||||
<div className="text-center p-4 bg-[var(--bg-secondary)] rounded-lg">
|
||||
<AlertTriangle className="w-8 h-8 mx-auto text-orange-600 mb-2" />
|
||||
<p className="text-2xl font-bold text-[var(--text-primary)]">{warningCount}</p>
|
||||
<p className="text-sm text-[var(--text-secondary)]">{t('equipment.stats.needs_attention')}</p>
|
||||
</div>
|
||||
<div className="text-center p-4 bg-[var(--bg-secondary)] rounded-lg">
|
||||
<div className="w-8 h-8 mx-auto bg-blue-100 dark:bg-blue-900/20 rounded-full flex items-center justify-center mb-2">
|
||||
<span className="text-blue-600 font-bold text-lg">{avgEfficiency.toFixed(0)}%</span>
|
||||
</div>
|
||||
<p className="text-2xl font-bold text-[var(--text-primary)]">{avgEfficiency.toFixed(1)}%</p>
|
||||
<p className="text-sm text-[var(--text-secondary)]">{t('equipment.stats.avg_efficiency')}</p>
|
||||
</div>
|
||||
<div className="text-center p-4 bg-[var(--bg-secondary)] rounded-lg">
|
||||
<AlertTriangle className="w-8 h-8 mx-auto text-red-600 mb-2" />
|
||||
<p className="text-2xl font-bold text-[var(--text-primary)]">
|
||||
{equipment.reduce((sum, e) => sum + e.alertCount, 0)}
|
||||
</p>
|
||||
<p className="text-sm text-[var(--text-secondary)]">{t('equipment.stats.alerts')}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Equipment List */}
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-[var(--text-primary)] mb-3 flex items-center">
|
||||
<Settings className="w-4 h-4 mr-2" />
|
||||
{t('equipment.status.equipment_list')} ({equipment.length})
|
||||
</h4>
|
||||
<div className="space-y-3">
|
||||
{equipment.map((item) => {
|
||||
const StatusIcon = getStatusIcon(item.status);
|
||||
return (
|
||||
<div key={item.id} className="p-4 bg-[var(--bg-secondary)] rounded-lg hover:bg-[var(--bg-tertiary)] transition-colors">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-3">
|
||||
<StatusIcon className={`w-5 h-5 ${getStatusColor(item.status)}`} />
|
||||
<div>
|
||||
<p className="font-medium text-[var(--text-primary)]">{item.name}</p>
|
||||
<div className="flex items-center space-x-4 text-xs text-[var(--text-secondary)]">
|
||||
<span>{t('equipment.efficiency')}: {item.efficiency}%</span>
|
||||
<span>{t('equipment.uptime')}: {item.uptime}%</span>
|
||||
{item.temperature && (
|
||||
<span>{t('equipment.temperature')}: {item.temperature}°C</span>
|
||||
)}
|
||||
{item.alertCount > 0 && (
|
||||
<span className="text-red-600">{item.alertCount} {t('equipment.unread_alerts')}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Badge variant={getStatusBadgeVariant(item.status)}>
|
||||
{t(`equipment.status.${item.status}`)}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Equipment Charts */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{/* Efficiency Chart */}
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-[var(--text-primary)] mb-3">
|
||||
{t('equipment.efficiency')} {t('stats.by_equipment')}
|
||||
</h4>
|
||||
<AnalyticsChart
|
||||
series={getEfficiencyChartData()}
|
||||
height={200}
|
||||
showLegend={false}
|
||||
showGrid={true}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Status Distribution */}
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-[var(--text-primary)] mb-3">
|
||||
{t('equipment.status.distribution')}
|
||||
</h4>
|
||||
<AnalyticsChart
|
||||
series={getStatusDistributionChartData()}
|
||||
height={200}
|
||||
showLegend={true}
|
||||
showGrid={false}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Maintenance Alerts */}
|
||||
{equipment.some(e => e.alertCount > 0 || e.status === 'maintenance') && (
|
||||
<div className="p-4 bg-orange-50 dark:bg-orange-900/20 rounded-lg">
|
||||
<div className="flex items-start space-x-2">
|
||||
<AlertTriangle className="w-5 h-5 text-orange-600 mt-0.5" />
|
||||
<div>
|
||||
<p className="text-sm font-medium text-orange-600">
|
||||
{t('equipment.alerts.maintenance_required')}
|
||||
</p>
|
||||
<p className="text-xs text-[var(--text-secondary)] mt-1">
|
||||
{equipment.filter(e => e.status === 'maintenance').length} {t('equipment.alerts.equipment_in_maintenance')},
|
||||
{equipment.reduce((sum, e) => sum + e.alertCount, 0)} {t('equipment.alerts.active_alerts')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</AnalyticsWidget>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,259 @@
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Activity, Clock, AlertCircle, CheckCircle, Play, Pause } from 'lucide-react';
|
||||
import { AnalyticsWidget } from '../AnalyticsWidget';
|
||||
import { Badge, ProgressBar, Button } from '../../../ui';
|
||||
import { useActiveBatches } from '../../../../api/hooks/production';
|
||||
import { useCurrentTenant } from '../../../../stores/tenant.store';
|
||||
import { ProductionStatus } from '../../../../api/types/production';
|
||||
|
||||
export const LiveBatchTrackerWidget: React.FC = () => {
|
||||
const { t } = useTranslation('production');
|
||||
const currentTenant = useCurrentTenant();
|
||||
const tenantId = currentTenant?.id || '';
|
||||
|
||||
const { data: batchesData, isLoading, error } = useActiveBatches(tenantId);
|
||||
const batches = batchesData?.batches || [];
|
||||
|
||||
const getStatusIcon = (status: ProductionStatus) => {
|
||||
switch (status) {
|
||||
case ProductionStatus.COMPLETED:
|
||||
return CheckCircle;
|
||||
case ProductionStatus.IN_PROGRESS:
|
||||
return Play;
|
||||
case ProductionStatus.ON_HOLD:
|
||||
return Pause;
|
||||
case ProductionStatus.FAILED:
|
||||
return AlertCircle;
|
||||
default:
|
||||
return Clock;
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusBadgeVariant = (status: ProductionStatus) => {
|
||||
switch (status) {
|
||||
case ProductionStatus.COMPLETED:
|
||||
return 'success';
|
||||
case ProductionStatus.IN_PROGRESS:
|
||||
return 'info';
|
||||
case ProductionStatus.PENDING:
|
||||
return 'warning';
|
||||
case ProductionStatus.ON_HOLD:
|
||||
return 'warning';
|
||||
case ProductionStatus.CANCELLED:
|
||||
case ProductionStatus.FAILED:
|
||||
return 'error';
|
||||
default:
|
||||
return 'default';
|
||||
}
|
||||
};
|
||||
|
||||
const calculateProgress = (batch: any) => {
|
||||
if (batch.status === ProductionStatus.COMPLETED) return 100;
|
||||
if (batch.status === ProductionStatus.PENDING) return 0;
|
||||
|
||||
if (batch.actual_start_time && batch.planned_duration_minutes) {
|
||||
const startTime = new Date(batch.actual_start_time).getTime();
|
||||
const now = new Date().getTime();
|
||||
const elapsed = (now - startTime) / (1000 * 60); // minutes
|
||||
const progress = Math.min((elapsed / batch.planned_duration_minutes) * 100, 100);
|
||||
return Math.round(progress);
|
||||
}
|
||||
|
||||
return 25; // Default for in-progress without timing info
|
||||
};
|
||||
|
||||
const calculateETA = (batch: any) => {
|
||||
if (batch.status === ProductionStatus.COMPLETED) return null;
|
||||
if (!batch.actual_start_time || !batch.planned_duration_minutes) return null;
|
||||
|
||||
const startTime = new Date(batch.actual_start_time).getTime();
|
||||
const eta = new Date(startTime + batch.planned_duration_minutes * 60 * 1000);
|
||||
const now = new Date();
|
||||
|
||||
if (eta < now) {
|
||||
const delay = Math.round((now.getTime() - eta.getTime()) / (1000 * 60));
|
||||
return `${delay}m ${t('schedule.delayed')}`;
|
||||
}
|
||||
|
||||
return eta.toLocaleTimeString('es-ES', { hour: '2-digit', minute: '2-digit' });
|
||||
};
|
||||
|
||||
const getProgressBarVariant = (status: ProductionStatus, progress: number) => {
|
||||
if (status === ProductionStatus.COMPLETED) return 'success';
|
||||
if (status === ProductionStatus.FAILED || status === ProductionStatus.CANCELLED) return 'error';
|
||||
if (progress > 90) return 'warning';
|
||||
return 'info';
|
||||
};
|
||||
|
||||
return (
|
||||
<AnalyticsWidget
|
||||
title={t('tracker.live_batch_tracker')}
|
||||
subtitle={t('tracker.current_batches_status_progress')}
|
||||
icon={Activity}
|
||||
loading={isLoading}
|
||||
error={error?.message}
|
||||
actions={
|
||||
<Button variant="outline" size="sm">
|
||||
<Activity className="w-4 h-4 mr-1" />
|
||||
{t('actions.refresh')}
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<div className="space-y-3">
|
||||
{batches.length === 0 ? (
|
||||
<div className="text-center py-8 text-[var(--text-secondary)]">
|
||||
<Activity className="w-12 h-12 mx-auto mb-3 opacity-50" />
|
||||
<h3 className="text-lg font-medium text-[var(--text-primary)] mb-2">
|
||||
{t('messages.no_active_batches')}
|
||||
</h3>
|
||||
<p className="text-sm">{t('messages.no_active_batches_description')}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3 max-h-96 overflow-y-auto">
|
||||
{batches.map((batch) => {
|
||||
const StatusIcon = getStatusIcon(batch.status);
|
||||
const progress = calculateProgress(batch);
|
||||
const eta = calculateETA(batch);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={batch.id}
|
||||
className="p-4 bg-[var(--bg-secondary)] rounded-lg border border-[var(--border-primary)] hover:shadow-md transition-all"
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-start justify-between mb-3">
|
||||
<div className="flex items-start space-x-3 flex-1">
|
||||
<div className="w-8 h-8 bg-[var(--color-primary)]/10 rounded-lg flex items-center justify-center mt-0.5">
|
||||
<StatusIcon className="w-4 h-4 text-[var(--color-primary)]" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h4 className="font-semibold text-[var(--text-primary)] mb-1">
|
||||
{batch.product_name}
|
||||
</h4>
|
||||
<p className="text-sm text-[var(--text-secondary)] mb-1">
|
||||
{t('batch.batch_number')}: {batch.batch_number}
|
||||
</p>
|
||||
<div className="flex items-center space-x-4 text-xs text-[var(--text-tertiary)]">
|
||||
<span>{batch.planned_quantity} {t('common.units')}</span>
|
||||
{batch.actual_quantity && (
|
||||
<span>
|
||||
{t('batch.actual')}: {batch.actual_quantity} {t('common.units')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Badge variant={getStatusBadgeVariant(batch.status)}>
|
||||
{t(`status.${batch.status.toLowerCase()}`)}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{/* Progress Bar */}
|
||||
{batch.status !== ProductionStatus.PENDING && (
|
||||
<div className="mb-3">
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<span className="text-xs text-[var(--text-secondary)]">
|
||||
{t('tracker.progress')}
|
||||
</span>
|
||||
<span className="text-xs font-medium text-[var(--text-primary)]">
|
||||
{progress}%
|
||||
</span>
|
||||
</div>
|
||||
<ProgressBar
|
||||
value={progress}
|
||||
variant={getProgressBarVariant(batch.status, progress)}
|
||||
size="sm"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Details */}
|
||||
<div className="flex items-center justify-between text-xs">
|
||||
<div className="flex items-center space-x-4">
|
||||
{batch.priority && (
|
||||
<span className={`font-medium ${
|
||||
batch.priority === 'URGENT' ? 'text-red-600' :
|
||||
batch.priority === 'HIGH' ? 'text-orange-600' :
|
||||
batch.priority === 'MEDIUM' ? 'text-blue-600' :
|
||||
'text-gray-600'
|
||||
}`}>
|
||||
{t(`priority.${batch.priority.toLowerCase()}`)}
|
||||
</span>
|
||||
)}
|
||||
{batch.is_rush_order && (
|
||||
<span className="text-red-600 font-medium">
|
||||
{t('batch.rush_order')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center space-x-1 text-[var(--text-secondary)]">
|
||||
<Clock className="w-3 h-3" />
|
||||
<span>
|
||||
{eta ? (
|
||||
<span className={eta.includes('delayed') ? 'text-red-600' : ''}>
|
||||
ETA: {eta}
|
||||
</span>
|
||||
) : (
|
||||
t('tracker.pending_start')
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Equipment & Staff */}
|
||||
{(batch.equipment_used?.length > 0 || batch.staff_assigned?.length > 0) && (
|
||||
<div className="mt-2 pt-2 border-t border-[var(--border-primary)] text-xs text-[var(--text-tertiary)]">
|
||||
{batch.equipment_used?.length > 0 && (
|
||||
<div className="mb-1">
|
||||
{t('batch.equipment')}: {batch.equipment_used.join(', ')}
|
||||
</div>
|
||||
)}
|
||||
{batch.staff_assigned?.length > 0 && (
|
||||
<div>
|
||||
{t('batch.staff')}: {batch.staff_assigned.length} {t('common.assigned')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Summary */}
|
||||
{batches.length > 0 && (
|
||||
<div className="pt-3 border-t border-[var(--border-primary)]">
|
||||
<div className="grid grid-cols-4 gap-4 text-center text-xs">
|
||||
<div>
|
||||
<p className="font-medium text-green-600">
|
||||
{batches.filter(b => b.status === ProductionStatus.COMPLETED).length}
|
||||
</p>
|
||||
<p className="text-[var(--text-tertiary)]">{t('status.completed')}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium text-blue-600">
|
||||
{batches.filter(b => b.status === ProductionStatus.IN_PROGRESS).length}
|
||||
</p>
|
||||
<p className="text-[var(--text-tertiary)]">{t('status.in_progress')}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium text-orange-600">
|
||||
{batches.filter(b => b.status === ProductionStatus.PENDING).length}
|
||||
</p>
|
||||
<p className="text-[var(--text-tertiary)]">{t('status.pending')}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium text-red-600">
|
||||
{batches.filter(b => [ProductionStatus.FAILED, ProductionStatus.CANCELLED].includes(b.status)).length}
|
||||
</p>
|
||||
<p className="text-[var(--text-tertiary)]">{t('tracker.issues')}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</AnalyticsWidget>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,305 @@
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Calendar, Clock, Wrench, AlertCircle, CheckCircle2 } from 'lucide-react';
|
||||
import { AnalyticsWidget } from '../AnalyticsWidget';
|
||||
import { Badge, Button } from '../../../ui';
|
||||
import { useCurrentTenant } from '../../../../stores/tenant.store';
|
||||
|
||||
interface MaintenanceTask {
|
||||
id: string;
|
||||
equipmentId: string;
|
||||
equipmentName: string;
|
||||
type: 'preventive' | 'corrective' | 'inspection';
|
||||
priority: 'low' | 'medium' | 'high' | 'urgent';
|
||||
status: 'scheduled' | 'in_progress' | 'completed' | 'overdue';
|
||||
scheduledDate: string;
|
||||
completedDate?: string;
|
||||
estimatedDuration: number; // in hours
|
||||
actualDuration?: number;
|
||||
technician?: string;
|
||||
description: string;
|
||||
cost?: number;
|
||||
}
|
||||
|
||||
export const MaintenanceScheduleWidget: React.FC = () => {
|
||||
const { t } = useTranslation('production');
|
||||
const currentTenant = useCurrentTenant();
|
||||
|
||||
// Mock maintenance data - replace with real API call
|
||||
const maintenanceTasks: MaintenanceTask[] = [
|
||||
{
|
||||
id: '1',
|
||||
equipmentId: '1',
|
||||
equipmentName: 'Horno Principal',
|
||||
type: 'preventive',
|
||||
priority: 'high',
|
||||
status: 'scheduled',
|
||||
scheduledDate: '2024-01-28',
|
||||
estimatedDuration: 4,
|
||||
description: 'Limpieza profunda y calibración de temperatura',
|
||||
cost: 150
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
equipmentId: '2',
|
||||
equipmentName: 'Mezcladora A',
|
||||
type: 'corrective',
|
||||
priority: 'urgent',
|
||||
status: 'overdue',
|
||||
scheduledDate: '2024-01-25',
|
||||
estimatedDuration: 2,
|
||||
description: 'Reparación de motor y reemplazo de correas',
|
||||
cost: 280
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
equipmentId: '3',
|
||||
equipmentName: 'Cámara de Fermentación 1',
|
||||
type: 'inspection',
|
||||
priority: 'medium',
|
||||
status: 'in_progress',
|
||||
scheduledDate: '2024-01-26',
|
||||
estimatedDuration: 1,
|
||||
technician: 'Carlos López',
|
||||
description: 'Inspección rutinaria de sistemas de control',
|
||||
cost: 80
|
||||
},
|
||||
{
|
||||
id: '4',
|
||||
equipmentId: '4',
|
||||
equipmentName: 'Empaquetadora',
|
||||
type: 'preventive',
|
||||
priority: 'medium',
|
||||
status: 'completed',
|
||||
scheduledDate: '2024-01-24',
|
||||
completedDate: '2024-01-24',
|
||||
estimatedDuration: 3,
|
||||
actualDuration: 2.5,
|
||||
technician: 'Ana García',
|
||||
description: 'Mantenimiento preventivo mensual',
|
||||
cost: 120
|
||||
},
|
||||
{
|
||||
id: '5',
|
||||
equipmentId: '1',
|
||||
equipmentName: 'Horno Principal',
|
||||
type: 'inspection',
|
||||
priority: 'low',
|
||||
status: 'scheduled',
|
||||
scheduledDate: '2024-02-05',
|
||||
estimatedDuration: 0.5,
|
||||
description: 'Inspección de seguridad semanal'
|
||||
}
|
||||
];
|
||||
|
||||
const getStatusColor = (status: MaintenanceTask['status']) => {
|
||||
switch (status) {
|
||||
case 'completed': return 'text-green-600';
|
||||
case 'in_progress': return 'text-blue-600';
|
||||
case 'scheduled': return 'text-orange-600';
|
||||
case 'overdue': return 'text-red-600';
|
||||
default: return 'text-gray-600';
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusBadgeVariant = (status: MaintenanceTask['status']) => {
|
||||
switch (status) {
|
||||
case 'completed': return 'success';
|
||||
case 'in_progress': return 'info';
|
||||
case 'scheduled': return 'warning';
|
||||
case 'overdue': return 'error';
|
||||
default: return 'default';
|
||||
}
|
||||
};
|
||||
|
||||
const getPriorityColor = (priority: MaintenanceTask['priority']) => {
|
||||
switch (priority) {
|
||||
case 'urgent': return 'text-red-600';
|
||||
case 'high': return 'text-orange-600';
|
||||
case 'medium': return 'text-blue-600';
|
||||
case 'low': return 'text-gray-600';
|
||||
default: return 'text-gray-600';
|
||||
}
|
||||
};
|
||||
|
||||
const getTypeIcon = (type: MaintenanceTask['type']) => {
|
||||
switch (type) {
|
||||
case 'preventive': return Calendar;
|
||||
case 'corrective': return Wrench;
|
||||
case 'inspection': return CheckCircle2;
|
||||
default: return Wrench;
|
||||
}
|
||||
};
|
||||
|
||||
const formatDate = (dateString: string) => {
|
||||
return new Date(dateString).toLocaleDateString('es-ES', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric'
|
||||
});
|
||||
};
|
||||
|
||||
const scheduledTasks = maintenanceTasks.filter(t => t.status === 'scheduled');
|
||||
const overdueTasks = maintenanceTasks.filter(t => t.status === 'overdue');
|
||||
const inProgressTasks = maintenanceTasks.filter(t => t.status === 'in_progress');
|
||||
const completedTasks = maintenanceTasks.filter(t => t.status === 'completed');
|
||||
|
||||
const totalCost = maintenanceTasks.reduce((sum, task) => sum + (task.cost || 0), 0);
|
||||
const avgDuration = maintenanceTasks.reduce((sum, task) => sum + task.estimatedDuration, 0) / maintenanceTasks.length;
|
||||
|
||||
return (
|
||||
<AnalyticsWidget
|
||||
title={t('equipment.maintenance.title')}
|
||||
subtitle={t('equipment.maintenance.subtitle')}
|
||||
icon={Calendar}
|
||||
actions={
|
||||
<div className="flex space-x-2">
|
||||
<Button variant="outline" size="sm">
|
||||
<Calendar className="w-4 h-4 mr-1" />
|
||||
{t('equipment.actions.schedule_maintenance')}
|
||||
</Button>
|
||||
<Button variant="primary" size="sm">
|
||||
<Wrench className="w-4 h-4 mr-1" />
|
||||
{t('equipment.actions.add_task')}
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className="space-y-6">
|
||||
{/* Maintenance Overview Stats */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<div className="text-center p-4 bg-[var(--bg-secondary)] rounded-lg">
|
||||
<Calendar className="w-8 h-8 mx-auto text-orange-600 mb-2" />
|
||||
<p className="text-2xl font-bold text-[var(--text-primary)]">{scheduledTasks.length}</p>
|
||||
<p className="text-sm text-[var(--text-secondary)]">{t('equipment.maintenance.scheduled')}</p>
|
||||
</div>
|
||||
<div className="text-center p-4 bg-[var(--bg-secondary)] rounded-lg">
|
||||
<AlertCircle className="w-8 h-8 mx-auto text-red-600 mb-2" />
|
||||
<p className="text-2xl font-bold text-[var(--text-primary)]">{overdueTasks.length}</p>
|
||||
<p className="text-sm text-[var(--text-secondary)]">{t('equipment.maintenance.overdue')}</p>
|
||||
</div>
|
||||
<div className="text-center p-4 bg-[var(--bg-secondary)] rounded-lg">
|
||||
<Clock className="w-8 h-8 mx-auto text-blue-600 mb-2" />
|
||||
<p className="text-2xl font-bold text-[var(--text-primary)]">{avgDuration.toFixed(1)}h</p>
|
||||
<p className="text-sm text-[var(--text-secondary)]">{t('equipment.maintenance.avg_duration')}</p>
|
||||
</div>
|
||||
<div className="text-center p-4 bg-[var(--bg-secondary)] rounded-lg">
|
||||
<div className="w-8 h-8 mx-auto bg-green-100 dark:bg-green-900/20 rounded-full flex items-center justify-center mb-2">
|
||||
<span className="text-green-600 font-bold text-sm">€</span>
|
||||
</div>
|
||||
<p className="text-2xl font-bold text-[var(--text-primary)]">€{totalCost}</p>
|
||||
<p className="text-sm text-[var(--text-secondary)]">{t('equipment.maintenance.total_cost')}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Priority Tasks */}
|
||||
{overdueTasks.length > 0 && (
|
||||
<div className="p-4 bg-red-50 dark:bg-red-900/20 rounded-lg">
|
||||
<div className="flex items-start space-x-2">
|
||||
<AlertCircle className="w-5 h-5 text-red-600 mt-0.5" />
|
||||
<div>
|
||||
<p className="text-sm font-medium text-red-600">
|
||||
{t('equipment.maintenance.overdue_tasks')} ({overdueTasks.length})
|
||||
</p>
|
||||
<p className="text-xs text-[var(--text-secondary)] mt-1">
|
||||
{t('equipment.maintenance.immediate_attention_required')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Maintenance Tasks List */}
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-[var(--text-primary)] mb-3 flex items-center">
|
||||
<Wrench className="w-4 h-4 mr-2" />
|
||||
{t('equipment.maintenance.tasks')} ({maintenanceTasks.length})
|
||||
</h4>
|
||||
<div className="space-y-3">
|
||||
{maintenanceTasks.map((task) => {
|
||||
const TypeIcon = getTypeIcon(task.type);
|
||||
return (
|
||||
<div key={task.id} className="p-4 bg-[var(--bg-secondary)] rounded-lg hover:bg-[var(--bg-tertiary)] transition-colors">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-start space-x-3">
|
||||
<TypeIcon className={`w-5 h-5 mt-1 ${getStatusColor(task.status)}`} />
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center space-x-2 mb-1">
|
||||
<p className="font-medium text-[var(--text-primary)]">{task.equipmentName}</p>
|
||||
<span className={`text-xs font-medium ${getPriorityColor(task.priority)}`}>
|
||||
{t(`priority.${task.priority}`)}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm text-[var(--text-secondary)] mb-2">{task.description}</p>
|
||||
<div className="flex items-center space-x-4 text-xs text-[var(--text-secondary)]">
|
||||
<span>{t('equipment.maintenance.scheduled')}: {formatDate(task.scheduledDate)}</span>
|
||||
<span>{t('equipment.maintenance.duration')}: {task.estimatedDuration}h</span>
|
||||
{task.cost && <span>{t('equipment.maintenance.cost')}: €{task.cost}</span>}
|
||||
{task.technician && <span>{t('equipment.maintenance.technician')}: {task.technician}</span>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col items-end space-y-2">
|
||||
<Badge variant={getStatusBadgeVariant(task.status)}>
|
||||
{t(`equipment.maintenance.status.${task.status}`)}
|
||||
</Badge>
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{t(`equipment.maintenance.type.${task.type}`)}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Weekly Schedule Preview */}
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-[var(--text-primary)] mb-3 flex items-center">
|
||||
<Calendar className="w-4 h-4 mr-2" />
|
||||
{t('equipment.maintenance.this_week')}
|
||||
</h4>
|
||||
<div className="grid grid-cols-7 gap-1">
|
||||
{Array.from({ length: 7 }, (_, i) => {
|
||||
const date = new Date();
|
||||
date.setDate(date.getDate() + i);
|
||||
const dateStr = date.toISOString().split('T')[0];
|
||||
const tasksForDay = maintenanceTasks.filter(task => task.scheduledDate === dateStr);
|
||||
|
||||
return (
|
||||
<div key={i} className="p-2 bg-[var(--bg-secondary)] rounded text-center">
|
||||
<p className="text-xs text-[var(--text-secondary)] mb-1">
|
||||
{date.toLocaleDateString('es-ES', { weekday: 'short' })}
|
||||
</p>
|
||||
<p className="text-sm font-medium text-[var(--text-primary)]">
|
||||
{date.getDate()}
|
||||
</p>
|
||||
{tasksForDay.length > 0 && (
|
||||
<div className="w-2 h-2 bg-orange-500 rounded-full mx-auto mt-1"></div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Maintenance Insights */}
|
||||
<div className="p-4 bg-blue-50 dark:bg-blue-900/20 rounded-lg">
|
||||
<div className="flex items-start space-x-2">
|
||||
<CheckCircle2 className="w-5 h-5 text-blue-600 mt-0.5" />
|
||||
<div>
|
||||
<p className="text-sm font-medium text-blue-600">
|
||||
{t('equipment.maintenance.insights.title')}
|
||||
</p>
|
||||
<p className="text-xs text-[var(--text-secondary)] mt-1">
|
||||
{completedTasks.length} {t('equipment.maintenance.insights.completed_this_month')},
|
||||
{scheduledTasks.length} {t('equipment.maintenance.insights.scheduled_next_week')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</AnalyticsWidget>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,195 @@
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Clock, TrendingUp, TrendingDown, Target } from 'lucide-react';
|
||||
import { AnalyticsWidget } from '../AnalyticsWidget';
|
||||
import { AnalyticsChart, ChartSeries } from '../AnalyticsChart';
|
||||
import { Button } from '../../../ui';
|
||||
import { useProductionDashboard } from '../../../../api/hooks/production';
|
||||
import { useCurrentTenant } from '../../../../stores/tenant.store';
|
||||
|
||||
export const OnTimeCompletionWidget: React.FC = () => {
|
||||
const { t } = useTranslation('production');
|
||||
const currentTenant = useCurrentTenant();
|
||||
const tenantId = currentTenant?.id || '';
|
||||
|
||||
const { data: dashboard, isLoading, error } = useProductionDashboard(tenantId);
|
||||
|
||||
// Mock historical data for the chart (in a real implementation, this would come from an analytics API)
|
||||
const getHistoricalCompletionData = (): ChartSeries[] => {
|
||||
const mockData = [
|
||||
{ x: 'Lun', y: 92, label: 'Lunes' },
|
||||
{ x: 'Mar', y: 89, label: 'Martes' },
|
||||
{ x: 'Mié', y: 95, label: 'Miércoles' },
|
||||
{ x: 'Jue', y: 87, label: 'Jueves' },
|
||||
{ x: 'Vie', y: 94, label: 'Viernes' },
|
||||
{ x: 'Sáb', y: 98, label: 'Sábado' },
|
||||
{ x: 'Dom', y: dashboard?.on_time_completion_rate || 91, label: 'Domingo' },
|
||||
];
|
||||
|
||||
return [
|
||||
{
|
||||
id: 'completion-rate',
|
||||
name: t('stats.on_time_completion'),
|
||||
type: 'line',
|
||||
color: '#10b981',
|
||||
data: mockData
|
||||
}
|
||||
];
|
||||
};
|
||||
|
||||
const currentRate = dashboard?.on_time_completion_rate || 0;
|
||||
const targetRate = 95; // Target completion rate
|
||||
const weekAverage = 93; // Mock week average
|
||||
const trend = currentRate - weekAverage;
|
||||
|
||||
const getCompletionRateStatus = (rate: number) => {
|
||||
if (rate >= 95) return { color: 'text-green-600', icon: TrendingUp, status: 'excellent' };
|
||||
if (rate >= 90) return { color: 'text-blue-600', icon: TrendingUp, status: 'good' };
|
||||
if (rate >= 85) return { color: 'text-orange-600', icon: TrendingDown, status: 'warning' };
|
||||
return { color: 'text-red-600', icon: TrendingDown, status: 'critical' };
|
||||
};
|
||||
|
||||
const rateStatus = getCompletionRateStatus(currentRate);
|
||||
const RateIcon = rateStatus.icon;
|
||||
|
||||
const getPerformanceInsight = () => {
|
||||
if (currentRate >= targetRate) {
|
||||
return {
|
||||
message: t('insights.on_time_excellent'),
|
||||
color: 'text-green-600',
|
||||
bgColor: 'bg-green-50 dark:bg-green-900/20'
|
||||
};
|
||||
} else if (currentRate >= 90) {
|
||||
return {
|
||||
message: t('insights.on_time_good_room_improvement'),
|
||||
color: 'text-blue-600',
|
||||
bgColor: 'bg-blue-50 dark:bg-blue-900/20'
|
||||
};
|
||||
} else if (currentRate >= 85) {
|
||||
return {
|
||||
message: t('insights.on_time_needs_attention'),
|
||||
color: 'text-orange-600',
|
||||
bgColor: 'bg-orange-50 dark:bg-orange-900/20'
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
message: t('insights.on_time_critical_delays'),
|
||||
color: 'text-red-600',
|
||||
bgColor: 'bg-red-50 dark:bg-red-900/20'
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const insight = getPerformanceInsight();
|
||||
|
||||
return (
|
||||
<AnalyticsWidget
|
||||
title={t('stats.on_time_completion_rate')}
|
||||
subtitle={t('insights.on_time_vs_planned')}
|
||||
icon={Clock}
|
||||
loading={isLoading}
|
||||
error={error?.message}
|
||||
actions={
|
||||
<Button variant="outline" size="sm">
|
||||
<Target className="w-4 h-4 mr-1" />
|
||||
{t('actions.analyze_delays')}
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
{/* Current Rate Display */}
|
||||
<div className="text-center">
|
||||
<div className="flex items-center justify-center space-x-2 mb-2">
|
||||
<RateIcon className={`w-6 h-6 ${rateStatus.color}`} />
|
||||
<span className="text-3xl font-bold text-[var(--text-primary)]">
|
||||
{currentRate.toFixed(1)}%
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-center space-x-4 text-sm">
|
||||
<div className={`flex items-center space-x-1 ${trend >= 0 ? 'text-green-600' : 'text-red-600'}`}>
|
||||
{trend >= 0 ? (
|
||||
<TrendingUp className="w-4 h-4" />
|
||||
) : (
|
||||
<TrendingDown className="w-4 h-4" />
|
||||
)}
|
||||
<span>{Math.abs(trend).toFixed(1)}% {t('insights.vs_week_avg')}</span>
|
||||
</div>
|
||||
<div className="text-[var(--text-secondary)]">
|
||||
{t('insights.target')}: {targetRate}%
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Progress towards target */}
|
||||
<div className="p-3 bg-[var(--bg-secondary)] rounded-lg">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-sm text-[var(--text-secondary)]">{t('insights.progress_to_target')}</span>
|
||||
<span className="text-sm font-medium text-[var(--text-primary)]">
|
||||
{Math.min(currentRate, targetRate).toFixed(1)}% / {targetRate}%
|
||||
</span>
|
||||
</div>
|
||||
<div className="w-full bg-[var(--bg-tertiary)] rounded-full h-2">
|
||||
<div
|
||||
className={`h-2 rounded-full transition-all ${
|
||||
currentRate >= targetRate ? 'bg-green-500' :
|
||||
currentRate >= 90 ? 'bg-blue-500' :
|
||||
currentRate >= 85 ? 'bg-orange-500' : 'bg-red-500'
|
||||
}`}
|
||||
style={{ width: `${Math.min((currentRate / targetRate) * 100, 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Weekly Trend Chart */}
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-[var(--text-primary)] mb-3">
|
||||
{t('insights.weekly_trend')}
|
||||
</h4>
|
||||
<AnalyticsChart
|
||||
series={getHistoricalCompletionData()}
|
||||
height={150}
|
||||
showLegend={false}
|
||||
showGrid={true}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Performance Insight */}
|
||||
<div className={`p-3 rounded-lg ${insight.bgColor}`}>
|
||||
<div className="flex items-start space-x-2">
|
||||
<Clock className={`w-4 h-4 mt-0.5 ${insight.color}`} />
|
||||
<div>
|
||||
<p className={`text-sm font-medium ${insight.color}`}>
|
||||
{t('insights.performance_insight')}
|
||||
</p>
|
||||
<p className="text-sm text-[var(--text-secondary)] mt-1">
|
||||
{insight.message}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Key Metrics */}
|
||||
<div className="grid grid-cols-3 gap-3 text-center">
|
||||
<div className="p-3 bg-[var(--bg-secondary)] rounded-lg">
|
||||
<p className="text-lg font-bold text-[var(--text-primary)]">
|
||||
{Math.round(currentRate * 0.85)}
|
||||
</p>
|
||||
<p className="text-xs text-[var(--text-secondary)]">{t('insights.batches_on_time')}</p>
|
||||
</div>
|
||||
<div className="p-3 bg-[var(--bg-secondary)] rounded-lg">
|
||||
<p className="text-lg font-bold text-[var(--text-primary)]">
|
||||
{Math.round((100 - currentRate) * 0.15)}
|
||||
</p>
|
||||
<p className="text-xs text-[var(--text-secondary)]">{t('insights.batches_delayed')}</p>
|
||||
</div>
|
||||
<div className="p-3 bg-[var(--bg-secondary)] rounded-lg">
|
||||
<p className="text-lg font-bold text-[var(--text-primary)]">
|
||||
{Math.round(12 * (100 - currentRate) / 100)}
|
||||
</p>
|
||||
<p className="text-xs text-[var(--text-secondary)]">{t('insights.avg_delay_minutes')}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</AnalyticsWidget>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,437 @@
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Brain, AlertTriangle, TrendingDown, Calendar, Wrench, Target } from 'lucide-react';
|
||||
import { AnalyticsWidget } from '../AnalyticsWidget';
|
||||
import { AnalyticsChart, ChartSeries } from '../AnalyticsChart';
|
||||
import { Badge, Button } from '../../../ui';
|
||||
import { useCurrentTenant } from '../../../../stores/tenant.store';
|
||||
|
||||
interface PredictiveMaintenanceAlert {
|
||||
id: string;
|
||||
equipmentId: string;
|
||||
equipmentName: string;
|
||||
equipmentType: 'oven' | 'mixer' | 'proofer' | 'packaging';
|
||||
alertType: 'wear_prediction' | 'failure_risk' | 'performance_degradation' | 'component_replacement';
|
||||
severity: 'low' | 'medium' | 'high' | 'critical';
|
||||
confidence: number; // percentage
|
||||
predictedFailureDate: string;
|
||||
currentCondition: number; // percentage (100% = perfect, 0% = failed)
|
||||
degradationRate: number; // percentage per week
|
||||
affectedComponents: string[];
|
||||
recommendedActions: string[];
|
||||
estimatedCost: number;
|
||||
potentialDowntime: number; // hours
|
||||
riskScore: number; // 0-100
|
||||
dataPoints: Array<{
|
||||
date: string;
|
||||
condition: number;
|
||||
vibration?: number;
|
||||
temperature?: number;
|
||||
efficiency?: number;
|
||||
}>;
|
||||
}
|
||||
|
||||
export const PredictiveMaintenanceWidget: React.FC = () => {
|
||||
const { t } = useTranslation('production');
|
||||
const currentTenant = useCurrentTenant();
|
||||
|
||||
// Mock predictive maintenance data - replace with real ML API call
|
||||
const maintenanceAlerts: PredictiveMaintenanceAlert[] = [
|
||||
{
|
||||
id: '1',
|
||||
equipmentId: '2',
|
||||
equipmentName: 'Mezcladora A',
|
||||
equipmentType: 'mixer',
|
||||
alertType: 'wear_prediction',
|
||||
severity: 'high',
|
||||
confidence: 94,
|
||||
predictedFailureDate: '2024-02-15',
|
||||
currentCondition: 78,
|
||||
degradationRate: 3.2,
|
||||
affectedComponents: ['Motor principal', 'Correas de transmisión', 'Rodamientos'],
|
||||
recommendedActions: [
|
||||
'Reemplazar correas de transmisión',
|
||||
'Lubricar rodamientos',
|
||||
'Inspeccionar motor'
|
||||
],
|
||||
estimatedCost: 280,
|
||||
potentialDowntime: 4,
|
||||
riskScore: 85,
|
||||
dataPoints: [
|
||||
{ date: '2024-01-01', condition: 95, vibration: 1.2, temperature: 35, efficiency: 94 },
|
||||
{ date: '2024-01-07', condition: 91, vibration: 1.5, temperature: 37, efficiency: 92 },
|
||||
{ date: '2024-01-14', condition: 87, vibration: 1.8, temperature: 39, efficiency: 89 },
|
||||
{ date: '2024-01-21', condition: 83, vibration: 2.1, temperature: 41, efficiency: 87 },
|
||||
{ date: '2024-01-28', condition: 78, vibration: 2.4, temperature: 43, efficiency: 85 }
|
||||
]
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
equipmentId: '1',
|
||||
equipmentName: 'Horno Principal',
|
||||
equipmentType: 'oven',
|
||||
alertType: 'performance_degradation',
|
||||
severity: 'medium',
|
||||
confidence: 87,
|
||||
predictedFailureDate: '2024-03-10',
|
||||
currentCondition: 89,
|
||||
degradationRate: 1.8,
|
||||
affectedComponents: ['Sistema de calefacción', 'Sensores de temperatura'],
|
||||
recommendedActions: [
|
||||
'Calibrar sensores de temperatura',
|
||||
'Limpiar sistema de calefacción',
|
||||
'Verificar aislamiento térmico'
|
||||
],
|
||||
estimatedCost: 150,
|
||||
potentialDowntime: 2,
|
||||
riskScore: 65,
|
||||
dataPoints: [
|
||||
{ date: '2024-01-01', condition: 98, temperature: 180, efficiency: 96 },
|
||||
{ date: '2024-01-07', condition: 95, temperature: 178, efficiency: 95 },
|
||||
{ date: '2024-01-14', condition: 93, temperature: 176, efficiency: 94 },
|
||||
{ date: '2024-01-21', condition: 91, temperature: 174, efficiency: 93 },
|
||||
{ date: '2024-01-28', condition: 89, temperature: 172, efficiency: 92 }
|
||||
]
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
equipmentId: '4',
|
||||
equipmentName: 'Empaquetadora',
|
||||
equipmentType: 'packaging',
|
||||
alertType: 'component_replacement',
|
||||
severity: 'low',
|
||||
confidence: 76,
|
||||
predictedFailureDate: '2024-04-20',
|
||||
currentCondition: 92,
|
||||
degradationRate: 1.1,
|
||||
affectedComponents: ['Cinta transportadora', 'Sistema de sellado'],
|
||||
recommendedActions: [
|
||||
'Inspeccionar cinta transportadora',
|
||||
'Ajustar sistema de sellado',
|
||||
'Reemplazar filtros'
|
||||
],
|
||||
estimatedCost: 120,
|
||||
potentialDowntime: 1.5,
|
||||
riskScore: 35,
|
||||
dataPoints: [
|
||||
{ date: '2024-01-01', condition: 98, efficiency: 97 },
|
||||
{ date: '2024-01-07', condition: 96, efficiency: 96 },
|
||||
{ date: '2024-01-14', condition: 95, efficiency: 95 },
|
||||
{ date: '2024-01-21', condition: 94, efficiency: 94 },
|
||||
{ date: '2024-01-28', condition: 92, efficiency: 93 }
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
const getSeverityColor = (severity: PredictiveMaintenanceAlert['severity']) => {
|
||||
switch (severity) {
|
||||
case 'critical': return 'text-red-600';
|
||||
case 'high': return 'text-orange-600';
|
||||
case 'medium': return 'text-yellow-600';
|
||||
case 'low': return 'text-blue-600';
|
||||
default: return 'text-gray-600';
|
||||
}
|
||||
};
|
||||
|
||||
const getSeverityBadgeVariant = (severity: PredictiveMaintenanceAlert['severity']) => {
|
||||
switch (severity) {
|
||||
case 'critical': return 'error';
|
||||
case 'high': return 'warning';
|
||||
case 'medium': return 'warning';
|
||||
case 'low': return 'info';
|
||||
default: return 'default';
|
||||
}
|
||||
};
|
||||
|
||||
const getAlertTypeIcon = (type: PredictiveMaintenanceAlert['alertType']) => {
|
||||
switch (type) {
|
||||
case 'wear_prediction': return TrendingDown;
|
||||
case 'failure_risk': return AlertTriangle;
|
||||
case 'performance_degradation': return Target;
|
||||
case 'component_replacement': return Wrench;
|
||||
default: return AlertTriangle;
|
||||
}
|
||||
};
|
||||
|
||||
const formatDate = (dateString: string) => {
|
||||
return new Date(dateString).toLocaleDateString('es-ES', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric'
|
||||
});
|
||||
};
|
||||
|
||||
const getDaysUntilFailure = (failureDate: string) => {
|
||||
const today = new Date();
|
||||
const failure = new Date(failureDate);
|
||||
const diffTime = failure.getTime() - today.getTime();
|
||||
const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24));
|
||||
return diffDays;
|
||||
};
|
||||
|
||||
const criticalAlerts = maintenanceAlerts.filter(alert => alert.severity === 'critical');
|
||||
const highRiskAlerts = maintenanceAlerts.filter(alert => alert.riskScore > 70);
|
||||
const totalEstimatedCost = maintenanceAlerts.reduce((sum, alert) => sum + alert.estimatedCost, 0);
|
||||
const totalPotentialDowntime = maintenanceAlerts.reduce((sum, alert) => sum + alert.potentialDowntime, 0);
|
||||
const avgConfidence = maintenanceAlerts.reduce((sum, alert) => sum + alert.confidence, 0) / maintenanceAlerts.length;
|
||||
|
||||
// Create equipment condition trend chart
|
||||
const getConditionTrendChartData = (): ChartSeries[] => {
|
||||
return maintenanceAlerts.map((alert, index) => ({
|
||||
id: `condition-${alert.equipmentId}`,
|
||||
name: alert.equipmentName,
|
||||
type: 'line',
|
||||
color: ['#ef4444', '#f97316', '#eab308'][index % 3],
|
||||
data: alert.dataPoints.map(point => ({
|
||||
x: new Date(point.date).toLocaleDateString('es-ES', { month: 'short', day: 'numeric' }),
|
||||
y: point.condition,
|
||||
label: `${point.condition}%`
|
||||
}))
|
||||
}));
|
||||
};
|
||||
|
||||
// Create risk score distribution chart
|
||||
const getRiskDistributionChartData = (): ChartSeries[] => {
|
||||
const lowRisk = maintenanceAlerts.filter(a => a.riskScore <= 40).length;
|
||||
const mediumRisk = maintenanceAlerts.filter(a => a.riskScore > 40 && a.riskScore <= 70).length;
|
||||
const highRisk = maintenanceAlerts.filter(a => a.riskScore > 70).length;
|
||||
|
||||
return [
|
||||
{
|
||||
id: 'risk-distribution',
|
||||
name: 'Risk Distribution',
|
||||
type: 'doughnut',
|
||||
color: '#3b82f6',
|
||||
data: [
|
||||
{ x: t('ai.risk.low'), y: lowRisk, label: `${t('ai.risk.low')}: ${lowRisk}` },
|
||||
{ x: t('ai.risk.medium'), y: mediumRisk, label: `${t('ai.risk.medium')}: ${mediumRisk}` },
|
||||
{ x: t('ai.risk.high'), y: highRisk, label: `${t('ai.risk.high')}: ${highRisk}` }
|
||||
].filter(item => item.y > 0)
|
||||
}
|
||||
];
|
||||
};
|
||||
|
||||
return (
|
||||
<AnalyticsWidget
|
||||
title={t('ai.predictive_maintenance.title')}
|
||||
subtitle={t('ai.predictive_maintenance.subtitle')}
|
||||
icon={Brain}
|
||||
actions={
|
||||
<div className="flex space-x-2">
|
||||
<Button variant="outline" size="sm">
|
||||
<Calendar className="w-4 h-4 mr-1" />
|
||||
{t('ai.predictive_maintenance.schedule_all')}
|
||||
</Button>
|
||||
<Button variant="primary" size="sm">
|
||||
<Brain className="w-4 h-4 mr-1" />
|
||||
{t('ai.predictive_maintenance.retrain_model')}
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className="space-y-6">
|
||||
{/* Predictive Maintenance Overview Stats */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<div className="text-center p-4 bg-[var(--bg-secondary)] rounded-lg">
|
||||
<AlertTriangle className="w-8 h-8 mx-auto text-red-600 mb-2" />
|
||||
<p className="text-2xl font-bold text-[var(--text-primary)]">{highRiskAlerts.length}</p>
|
||||
<p className="text-sm text-[var(--text-secondary)]">{t('ai.predictive_maintenance.high_risk')}</p>
|
||||
</div>
|
||||
<div className="text-center p-4 bg-[var(--bg-secondary)] rounded-lg">
|
||||
<div className="w-8 h-8 mx-auto bg-orange-100 dark:bg-orange-900/20 rounded-full flex items-center justify-center mb-2">
|
||||
<span className="text-orange-600 font-bold text-sm">€</span>
|
||||
</div>
|
||||
<p className="text-2xl font-bold text-[var(--text-primary)]">€{totalEstimatedCost}</p>
|
||||
<p className="text-sm text-[var(--text-secondary)]">{t('ai.predictive_maintenance.estimated_cost')}</p>
|
||||
</div>
|
||||
<div className="text-center p-4 bg-[var(--bg-secondary)] rounded-lg">
|
||||
<div className="w-8 h-8 mx-auto bg-red-100 dark:bg-red-900/20 rounded-full flex items-center justify-center mb-2">
|
||||
<span className="text-red-600 font-bold text-xs">h</span>
|
||||
</div>
|
||||
<p className="text-2xl font-bold text-[var(--text-primary)]">{totalPotentialDowntime}</p>
|
||||
<p className="text-sm text-[var(--text-secondary)]">{t('ai.predictive_maintenance.potential_downtime')}</p>
|
||||
</div>
|
||||
<div className="text-center p-4 bg-[var(--bg-secondary)] rounded-lg">
|
||||
<div className="w-8 h-8 mx-auto bg-blue-100 dark:bg-blue-900/20 rounded-full flex items-center justify-center mb-2">
|
||||
<span className="text-blue-600 font-bold text-sm">%</span>
|
||||
</div>
|
||||
<p className="text-2xl font-bold text-[var(--text-primary)]">{avgConfidence.toFixed(0)}%</p>
|
||||
<p className="text-sm text-[var(--text-secondary)]">{t('ai.predictive_maintenance.avg_confidence')}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Critical Alerts */}
|
||||
{highRiskAlerts.length > 0 && (
|
||||
<div className="p-4 bg-red-50 dark:bg-red-900/20 rounded-lg">
|
||||
<div className="flex items-start space-x-2">
|
||||
<AlertTriangle className="w-5 h-5 text-red-600 mt-0.5" />
|
||||
<div>
|
||||
<p className="text-sm font-medium text-red-600">
|
||||
{t('ai.predictive_maintenance.high_risk_equipment')} ({highRiskAlerts.length})
|
||||
</p>
|
||||
<p className="text-xs text-[var(--text-secondary)] mt-1">
|
||||
{t('ai.predictive_maintenance.immediate_attention_required')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Maintenance Predictions */}
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-[var(--text-primary)] mb-3 flex items-center">
|
||||
<Brain className="w-4 h-4 mr-2" />
|
||||
{t('ai.predictive_maintenance.predictions')} ({maintenanceAlerts.length})
|
||||
</h4>
|
||||
<div className="space-y-4">
|
||||
{maintenanceAlerts.map((alert) => {
|
||||
const AlertTypeIcon = getAlertTypeIcon(alert.alertType);
|
||||
const daysUntilFailure = getDaysUntilFailure(alert.predictedFailureDate);
|
||||
|
||||
return (
|
||||
<div key={alert.id} className="p-4 bg-[var(--bg-secondary)] rounded-lg">
|
||||
<div className="flex items-start justify-between mb-3">
|
||||
<div className="flex items-start space-x-3">
|
||||
<AlertTypeIcon className={`w-5 h-5 mt-1 ${getSeverityColor(alert.severity)}`} />
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center space-x-2 mb-1">
|
||||
<p className="font-medium text-[var(--text-primary)]">{alert.equipmentName}</p>
|
||||
<Badge variant={getSeverityBadgeVariant(alert.severity)}>
|
||||
{t(`ai.severity.${alert.severity}`)}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="flex items-center space-x-4 text-xs text-[var(--text-secondary)] mb-2">
|
||||
<span>{t('ai.predictive_maintenance.confidence')}: {alert.confidence}%</span>
|
||||
<span>{t('ai.predictive_maintenance.risk_score')}: {alert.riskScore}/100</span>
|
||||
<span>{t('ai.predictive_maintenance.days_until_failure')}: {daysUntilFailure}</span>
|
||||
</div>
|
||||
<p className="text-sm text-[var(--text-secondary)] mb-2">
|
||||
{t(`ai.predictive_maintenance.alert_type.${alert.alertType}`)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<div className="text-lg font-bold text-[var(--text-primary)]">{alert.currentCondition}%</div>
|
||||
<div className="text-xs text-[var(--text-secondary)]">{t('ai.predictive_maintenance.condition')}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Condition Progress Bar */}
|
||||
<div className="mb-3">
|
||||
<div className="flex justify-between text-xs text-[var(--text-secondary)] mb-1">
|
||||
<span>{t('ai.predictive_maintenance.current_condition')}</span>
|
||||
<span>{alert.currentCondition}%</span>
|
||||
</div>
|
||||
<div className="w-full bg-[var(--bg-tertiary)] rounded-full h-2">
|
||||
<div
|
||||
className={`h-2 rounded-full transition-all duration-300 ${
|
||||
alert.currentCondition > 80 ? 'bg-green-500' :
|
||||
alert.currentCondition > 60 ? 'bg-yellow-500' :
|
||||
alert.currentCondition > 40 ? 'bg-orange-500' : 'bg-red-500'
|
||||
}`}
|
||||
style={{ width: `${alert.currentCondition}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Affected Components */}
|
||||
<div className="mb-3">
|
||||
<p className="text-xs font-medium text-[var(--text-primary)] mb-1">
|
||||
{t('ai.predictive_maintenance.affected_components')}:
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{alert.affectedComponents.map((component, index) => (
|
||||
<Badge key={index} variant="outline" className="text-xs">
|
||||
{component}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Recommended Actions */}
|
||||
<div className="mb-3">
|
||||
<p className="text-xs font-medium text-[var(--text-primary)] mb-1">
|
||||
{t('ai.predictive_maintenance.recommended_actions')}:
|
||||
</p>
|
||||
<ul className="text-xs text-[var(--text-secondary)] space-y-1">
|
||||
{alert.recommendedActions.map((action, index) => (
|
||||
<li key={index}>• {action}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{/* Cost and Downtime */}
|
||||
<div className="flex items-center justify-between p-3 bg-[var(--bg-tertiary)] rounded-lg">
|
||||
<div className="flex items-center space-x-4 text-xs">
|
||||
<span className="flex items-center space-x-1">
|
||||
<span className="w-2 h-2 bg-orange-500 rounded-full"></span>
|
||||
<span>{t('ai.predictive_maintenance.estimated_cost')}: €{alert.estimatedCost}</span>
|
||||
</span>
|
||||
<span className="flex items-center space-x-1">
|
||||
<span className="w-2 h-2 bg-red-500 rounded-full"></span>
|
||||
<span>{t('ai.predictive_maintenance.potential_downtime')}: {alert.potentialDowntime}h</span>
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex space-x-2">
|
||||
<Button variant="outline" size="sm">
|
||||
{t('ai.predictive_maintenance.schedule')}
|
||||
</Button>
|
||||
<Button variant="primary" size="sm">
|
||||
{t('ai.predictive_maintenance.details')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Charts */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{/* Equipment Condition Trends */}
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-[var(--text-primary)] mb-3">
|
||||
{t('ai.predictive_maintenance.condition_trends')}
|
||||
</h4>
|
||||
<AnalyticsChart
|
||||
series={getConditionTrendChartData()}
|
||||
height={200}
|
||||
showLegend={true}
|
||||
showGrid={true}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Risk Distribution */}
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-[var(--text-primary)] mb-3">
|
||||
{t('ai.predictive_maintenance.risk_distribution')}
|
||||
</h4>
|
||||
<AnalyticsChart
|
||||
series={getRiskDistributionChartData()}
|
||||
height={200}
|
||||
showLegend={true}
|
||||
showGrid={false}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ML Model Status */}
|
||||
<div className="p-4 bg-blue-50 dark:bg-blue-900/20 rounded-lg">
|
||||
<div className="flex items-start space-x-2">
|
||||
<Brain className="w-5 h-5 text-blue-600 mt-0.5" />
|
||||
<div>
|
||||
<p className="text-sm font-medium text-blue-600">
|
||||
{t('ai.predictive_maintenance.model_status')}
|
||||
</p>
|
||||
<p className="text-xs text-[var(--text-secondary)] mt-1">
|
||||
{t('ai.predictive_maintenance.last_training')}: {t('ai.predictive_maintenance.yesterday')}.
|
||||
{t('ai.predictive_maintenance.accuracy')}: 94%. {t('ai.predictive_maintenance.next_training')}: {t('ai.predictive_maintenance.in_7_days')}.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</AnalyticsWidget>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,257 @@
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Star, TrendingUp, TrendingDown, Award } from 'lucide-react';
|
||||
import { AnalyticsWidget } from '../AnalyticsWidget';
|
||||
import { AnalyticsChart, ChartSeries } from '../AnalyticsChart';
|
||||
import { Button } from '../../../ui';
|
||||
import { useActiveBatches } from '../../../../api/hooks/production';
|
||||
import { useCurrentTenant } from '../../../../stores/tenant.store';
|
||||
|
||||
export const QualityScoreTrendsWidget: React.FC = () => {
|
||||
const { t } = useTranslation('production');
|
||||
const currentTenant = useCurrentTenant();
|
||||
const tenantId = currentTenant?.id || '';
|
||||
|
||||
const { data: batchesData, isLoading, error } = useActiveBatches(tenantId);
|
||||
const batches = batchesData?.batches || [];
|
||||
|
||||
// Calculate quality score data
|
||||
const getQualityData = () => {
|
||||
const qualityScores = batches
|
||||
.filter(batch => batch.quality_score)
|
||||
.map(batch => batch.quality_score!);
|
||||
|
||||
if (qualityScores.length === 0) {
|
||||
return {
|
||||
averageScore: 0,
|
||||
totalChecks: 0,
|
||||
passRate: 0,
|
||||
trend: 0
|
||||
};
|
||||
}
|
||||
|
||||
const averageScore = qualityScores.reduce((sum, score) => sum + score, 0) / qualityScores.length;
|
||||
const totalChecks = qualityScores.length;
|
||||
const passRate = (qualityScores.filter(score => score >= 7).length / totalChecks) * 100;
|
||||
|
||||
// Mock trend calculation (would be compared with previous period in real implementation)
|
||||
const trend = 2.3; // +2.3 points vs last week
|
||||
|
||||
return {
|
||||
averageScore,
|
||||
totalChecks,
|
||||
passRate,
|
||||
trend
|
||||
};
|
||||
};
|
||||
|
||||
const qualityData = getQualityData();
|
||||
|
||||
// Create daily quality trend chart
|
||||
const getQualityTrendChartData = (): ChartSeries[] => {
|
||||
const days = ['Lun', 'Mar', 'Mié', 'Jue', 'Vie', 'Sáb', 'Dom'];
|
||||
const scores = days.map((day, index) => ({
|
||||
x: day,
|
||||
y: Math.max(6, 8.5 + Math.sin(index) * 0.8 + (Math.random() - 0.5) * 0.5),
|
||||
label: day
|
||||
}));
|
||||
|
||||
return [
|
||||
{
|
||||
id: 'quality-trend',
|
||||
name: t('quality.daily_quality_score'),
|
||||
type: 'line',
|
||||
color: '#16a34a',
|
||||
data: scores
|
||||
}
|
||||
];
|
||||
};
|
||||
|
||||
// Create quality distribution chart
|
||||
const getQualityDistributionChartData = (): ChartSeries[] => {
|
||||
// Mock quality score distribution
|
||||
const distribution = [
|
||||
{ range: '9-10', count: Math.round(qualityData.totalChecks * 0.35), label: t('quality.excellent') },
|
||||
{ range: '8-9', count: Math.round(qualityData.totalChecks * 0.30), label: t('quality.good') },
|
||||
{ range: '7-8', count: Math.round(qualityData.totalChecks * 0.20), label: t('quality.acceptable') },
|
||||
{ range: '6-7', count: Math.round(qualityData.totalChecks * 0.10), label: t('quality.poor') },
|
||||
{ range: '<6', count: Math.round(qualityData.totalChecks * 0.05), label: t('quality.failed') }
|
||||
].filter(item => item.count > 0);
|
||||
|
||||
return [
|
||||
{
|
||||
id: 'quality-distribution',
|
||||
name: t('quality.score_distribution'),
|
||||
type: 'bar',
|
||||
color: '#d97706',
|
||||
data: distribution.map(item => ({
|
||||
x: item.range,
|
||||
y: item.count,
|
||||
label: item.label
|
||||
}))
|
||||
}
|
||||
];
|
||||
};
|
||||
|
||||
const getScoreColor = (score: number) => {
|
||||
if (score >= 9) return 'text-green-600';
|
||||
if (score >= 8) return 'text-blue-600';
|
||||
if (score >= 7) return 'text-orange-600';
|
||||
return 'text-red-600';
|
||||
};
|
||||
|
||||
const getScoreStatus = (score: number) => {
|
||||
if (score >= 9) return { status: 'excellent', bgColor: 'bg-green-100 dark:bg-green-900/20' };
|
||||
if (score >= 8) return { status: 'good', bgColor: 'bg-blue-100 dark:bg-blue-900/20' };
|
||||
if (score >= 7) return { status: 'acceptable', bgColor: 'bg-orange-100 dark:bg-orange-900/20' };
|
||||
return { status: 'needs_improvement', bgColor: 'bg-red-100 dark:bg-red-900/20' };
|
||||
};
|
||||
|
||||
const scoreStatus = getScoreStatus(qualityData.averageScore);
|
||||
const TrendIcon = qualityData.trend >= 0 ? TrendingUp : TrendingDown;
|
||||
|
||||
return (
|
||||
<AnalyticsWidget
|
||||
title={t('quality.recent_quality_scores')}
|
||||
subtitle={t('quality.daily_average_quality_trends')}
|
||||
icon={Star}
|
||||
loading={isLoading}
|
||||
error={error?.message}
|
||||
actions={
|
||||
<Button variant="outline" size="sm">
|
||||
<Award className="w-4 h-4 mr-1" />
|
||||
{t('quality.actions.view_trends')}
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
{/* Quality Score Display */}
|
||||
<div className="text-center">
|
||||
<div className="flex items-center justify-center space-x-2 mb-2">
|
||||
<Star className={`w-6 h-6 ${getScoreColor(qualityData.averageScore)}`} />
|
||||
<span className="text-3xl font-bold text-[var(--text-primary)]">
|
||||
{qualityData.averageScore.toFixed(1)}/10
|
||||
</span>
|
||||
</div>
|
||||
<div className={`inline-flex items-center space-x-2 px-3 py-1 rounded-full text-sm ${scoreStatus.bgColor}`}>
|
||||
<span className={getScoreColor(qualityData.averageScore)}>
|
||||
{t(`quality.${scoreStatus.status}`)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-center space-x-1 mt-2">
|
||||
<TrendIcon className={`w-4 h-4 ${qualityData.trend >= 0 ? 'text-green-600' : 'text-red-600'}`} />
|
||||
<span className={`text-sm ${qualityData.trend >= 0 ? 'text-green-600' : 'text-red-600'}`}>
|
||||
{qualityData.trend > 0 ? '+' : ''}{qualityData.trend.toFixed(1)} {t('quality.vs_last_week')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Key Metrics */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="p-4 bg-[var(--bg-secondary)] rounded-lg text-center">
|
||||
<div className="text-2xl font-bold text-[var(--text-primary)] mb-1">
|
||||
{qualityData.totalChecks}
|
||||
</div>
|
||||
<p className="text-sm text-[var(--text-secondary)]">{t('stats.total_checks')}</p>
|
||||
</div>
|
||||
<div className="p-4 bg-[var(--bg-secondary)] rounded-lg text-center">
|
||||
<div className="text-2xl font-bold text-green-600 mb-1">
|
||||
{qualityData.passRate.toFixed(1)}%
|
||||
</div>
|
||||
<p className="text-sm text-[var(--text-secondary)]">{t('stats.pass_rate')}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{qualityData.totalChecks === 0 ? (
|
||||
<div className="text-center py-8 text-[var(--text-secondary)]">
|
||||
<Star className="w-12 h-12 mx-auto mb-3 opacity-50" />
|
||||
<p className="text-sm">{t('quality.no_quality_data')}</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Weekly Quality Trend */}
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-[var(--text-primary)] mb-3">
|
||||
{t('quality.weekly_quality_trends')}
|
||||
</h4>
|
||||
<AnalyticsChart
|
||||
series={getQualityTrendChartData()}
|
||||
height={150}
|
||||
showLegend={false}
|
||||
showGrid={true}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Quality Score Distribution */}
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-[var(--text-primary)] mb-3">
|
||||
{t('quality.score_distribution')}
|
||||
</h4>
|
||||
<AnalyticsChart
|
||||
series={getQualityDistributionChartData()}
|
||||
height={180}
|
||||
showLegend={false}
|
||||
showGrid={true}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Quality Insights */}
|
||||
<div className="grid grid-cols-3 gap-3 text-center">
|
||||
<div className="p-3 bg-[var(--bg-secondary)] rounded-lg">
|
||||
<p className="text-lg font-bold text-green-600">
|
||||
{Math.round(qualityData.totalChecks * 0.35)}
|
||||
</p>
|
||||
<p className="text-xs text-[var(--text-secondary)]">{t('quality.excellent_scores')}</p>
|
||||
</div>
|
||||
<div className="p-3 bg-[var(--bg-secondary)] rounded-lg">
|
||||
<p className="text-lg font-bold text-blue-600">
|
||||
{Math.round(qualityData.totalChecks * 0.30)}
|
||||
</p>
|
||||
<p className="text-xs text-[var(--text-secondary)]">{t('quality.good_scores')}</p>
|
||||
</div>
|
||||
<div className="p-3 bg-[var(--bg-secondary)] rounded-lg">
|
||||
<p className="text-lg font-bold text-red-600">
|
||||
{Math.round(qualityData.totalChecks * 0.15)}
|
||||
</p>
|
||||
<p className="text-xs text-[var(--text-secondary)]">{t('quality.needs_improvement')}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Quality Recommendations */}
|
||||
{qualityData.averageScore < 8 && (
|
||||
<div className="p-3 bg-orange-50 dark:bg-orange-900/20 rounded-lg">
|
||||
<div className="flex items-start space-x-2">
|
||||
<Star className="w-4 h-4 mt-0.5 text-orange-600" />
|
||||
<div>
|
||||
<p className="text-sm font-medium text-orange-600">
|
||||
{t('quality.recommendations.improve_quality')}
|
||||
</p>
|
||||
<p className="text-xs text-[var(--text-secondary)] mt-1">
|
||||
{t('quality.recommendations.focus_consistency')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{qualityData.averageScore >= 9 && (
|
||||
<div className="p-3 bg-green-50 dark:bg-green-900/20 rounded-lg">
|
||||
<div className="flex items-start space-x-2">
|
||||
<Award className="w-4 h-4 mt-0.5 text-green-600" />
|
||||
<div>
|
||||
<p className="text-sm font-medium text-green-600">
|
||||
{t('quality.recommendations.excellent_quality')}
|
||||
</p>
|
||||
<p className="text-xs text-[var(--text-secondary)] mt-1">
|
||||
{t('quality.recommendations.maintain_standards')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</AnalyticsWidget>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,176 @@
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Calendar, Clock, Users, Target, AlertCircle } from 'lucide-react';
|
||||
import { AnalyticsWidget } from '../AnalyticsWidget';
|
||||
import { Badge, Button } from '../../../ui';
|
||||
import { useProductionDashboard, useProductionSchedule } from '../../../../api/hooks/production';
|
||||
import { useCurrentTenant } from '../../../../stores/tenant.store';
|
||||
import { ProductionStatus } from '../../../../api/types/production';
|
||||
|
||||
export const TodaysScheduleSummaryWidget: React.FC = () => {
|
||||
const { t } = useTranslation('production');
|
||||
const currentTenant = useCurrentTenant();
|
||||
const tenantId = currentTenant?.id || '';
|
||||
|
||||
const { data: dashboard, isLoading: dashboardLoading, error: dashboardError } = useProductionDashboard(tenantId);
|
||||
const today = new Date().toISOString().split('T')[0];
|
||||
const { data: schedule, isLoading: scheduleLoading, error: scheduleError } = useProductionSchedule(tenantId, today, today);
|
||||
|
||||
const isLoading = dashboardLoading || scheduleLoading;
|
||||
const error = dashboardError?.message || scheduleError?.message;
|
||||
|
||||
const todaysSchedule = schedule?.schedules?.[0];
|
||||
const plannedBatches = dashboard?.todays_production_plan || [];
|
||||
|
||||
const getStatusBadgeVariant = (status: ProductionStatus) => {
|
||||
switch (status) {
|
||||
case ProductionStatus.COMPLETED:
|
||||
return 'success';
|
||||
case ProductionStatus.IN_PROGRESS:
|
||||
return 'info';
|
||||
case ProductionStatus.PENDING:
|
||||
return 'warning';
|
||||
case ProductionStatus.CANCELLED:
|
||||
case ProductionStatus.FAILED:
|
||||
return 'error';
|
||||
default:
|
||||
return 'default';
|
||||
}
|
||||
};
|
||||
|
||||
const getPriorityColor = (priority?: string) => {
|
||||
if (!priority) return 'text-gray-600';
|
||||
|
||||
switch (priority) {
|
||||
case 'URGENT':
|
||||
return 'text-red-600';
|
||||
case 'HIGH':
|
||||
return 'text-orange-600';
|
||||
case 'MEDIUM':
|
||||
return 'text-blue-600';
|
||||
case 'LOW':
|
||||
return 'text-gray-600';
|
||||
default:
|
||||
return 'text-gray-600';
|
||||
}
|
||||
};
|
||||
|
||||
const formatTime = (timeString: string) => {
|
||||
return new Date(timeString).toLocaleTimeString('es-ES', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<AnalyticsWidget
|
||||
title={t('schedule.todays_summary')}
|
||||
subtitle={t('schedule.shift_hours_batches_staff')}
|
||||
icon={Calendar}
|
||||
loading={isLoading}
|
||||
error={error}
|
||||
actions={
|
||||
<Button variant="outline" size="sm">
|
||||
<Target className="w-4 h-4 mr-1" />
|
||||
{t('actions.optimize')}
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
{/* Schedule Overview */}
|
||||
{todaysSchedule && (
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 p-4 bg-[var(--bg-secondary)] rounded-lg">
|
||||
<div className="text-center">
|
||||
<Clock className="w-5 h-5 text-[var(--color-primary)] mx-auto mb-1" />
|
||||
<p className="text-sm text-[var(--text-secondary)]">{t('schedule.shift_hours')}</p>
|
||||
<p className="font-semibold text-[var(--text-primary)]">
|
||||
{formatTime(todaysSchedule.shift_start)} - {formatTime(todaysSchedule.shift_end)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<Target className="w-5 h-5 text-[var(--color-primary)] mx-auto mb-1" />
|
||||
<p className="text-sm text-[var(--text-secondary)]">{t('stats.planned_batches')}</p>
|
||||
<p className="font-semibold text-[var(--text-primary)]">{todaysSchedule.total_batches_planned}</p>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<Users className="w-5 h-5 text-[var(--color-primary)] mx-auto mb-1" />
|
||||
<p className="text-sm text-[var(--text-secondary)]">{t('schedule.staff_count')}</p>
|
||||
<p className="font-semibold text-[var(--text-primary)]">{todaysSchedule.staff_count}</p>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<AlertCircle className="w-5 h-5 text-[var(--color-primary)] mx-auto mb-1" />
|
||||
<p className="text-sm text-[var(--text-secondary)]">{t('schedule.capacity_utilization')}</p>
|
||||
<p className="font-semibold text-[var(--text-primary)]">
|
||||
{todaysSchedule.utilization_percentage?.toFixed(1) || 0}%
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Planned Batches */}
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-[var(--text-primary)] mb-3 flex items-center">
|
||||
<Target className="w-4 h-4 mr-2" />
|
||||
{t('schedule.planned_batches')} ({plannedBatches.length})
|
||||
</h4>
|
||||
|
||||
{plannedBatches.length === 0 ? (
|
||||
<div className="text-center py-6 text-[var(--text-secondary)]">
|
||||
<Calendar className="w-8 h-8 mx-auto mb-2 opacity-50" />
|
||||
<p className="text-sm">{t('messages.no_batches_planned')}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2 max-h-64 overflow-y-auto">
|
||||
{plannedBatches.map((batch, index) => (
|
||||
<div
|
||||
key={batch.batch_id || index}
|
||||
className="flex items-center justify-between p-3 bg-[var(--bg-secondary)] rounded-lg hover:bg-[var(--bg-tertiary)] transition-colors"
|
||||
>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center space-x-2 mb-1">
|
||||
<p className="font-medium text-[var(--text-primary)] truncate">
|
||||
{batch.product_name}
|
||||
</p>
|
||||
{batch.priority && (
|
||||
<span className={`text-xs font-medium ${getPriorityColor(batch.priority)}`}>
|
||||
{t(`priority.${batch.priority.toLowerCase()}`)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-sm text-[var(--text-secondary)]">
|
||||
{t('batch.planned_quantity')}: {batch.planned_quantity} {t('common.units')}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Badge variant={getStatusBadgeVariant(batch.status)}>
|
||||
{batch.status ? t(`status.${batch.status.toLowerCase()}`) : t('status.unknown')}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Schedule Status */}
|
||||
{todaysSchedule && (
|
||||
<div className="flex items-center justify-between pt-3 border-t border-[var(--border-primary)]">
|
||||
<div className="flex items-center space-x-2 text-sm">
|
||||
<div className={`w-2 h-2 rounded-full ${todaysSchedule.is_active ? 'bg-green-500' : 'bg-gray-400'}`} />
|
||||
<span className="text-[var(--text-secondary)]">
|
||||
{todaysSchedule.is_active ? t('schedule.active') : t('schedule.inactive')}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-sm text-[var(--text-secondary)]">
|
||||
{todaysSchedule.is_finalized ? (
|
||||
<span className="text-green-600">{t('schedule.finalized')}</span>
|
||||
) : (
|
||||
<span className="text-orange-600">{t('schedule.draft')}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</AnalyticsWidget>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,329 @@
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { AlertCircle, Eye, Camera, CheckSquare } from 'lucide-react';
|
||||
import { AnalyticsWidget } from '../AnalyticsWidget';
|
||||
import { AnalyticsChart, ChartSeries } from '../AnalyticsChart';
|
||||
import { Button, Badge } from '../../../ui';
|
||||
import { useActiveBatches } from '../../../../api/hooks/production';
|
||||
import { useCurrentTenant } from '../../../../stores/tenant.store';
|
||||
|
||||
interface DefectType {
|
||||
type: string;
|
||||
count: number;
|
||||
percentage: number;
|
||||
severity: 'low' | 'medium' | 'high';
|
||||
trend: 'up' | 'down' | 'stable';
|
||||
estimatedCost: number;
|
||||
}
|
||||
|
||||
export const TopDefectTypesWidget: React.FC = () => {
|
||||
const { t } = useTranslation('production');
|
||||
const currentTenant = useCurrentTenant();
|
||||
const tenantId = currentTenant?.id || '';
|
||||
|
||||
const { data: batchesData, isLoading, error } = useActiveBatches(tenantId);
|
||||
const batches = batchesData?.batches || [];
|
||||
|
||||
// Calculate defect data from batches
|
||||
const getDefectData = (): DefectType[] => {
|
||||
const totalDefects = batches.reduce((sum, batch) => sum + (batch.defect_quantity || 0), 0);
|
||||
|
||||
if (totalDefects === 0) return [];
|
||||
|
||||
// Mock defect type distribution (in real implementation, this would come from quality check defect_types)
|
||||
const defectTypes = [
|
||||
{
|
||||
type: 'burnt',
|
||||
count: Math.round(totalDefects * 0.32),
|
||||
percentage: 32,
|
||||
severity: 'high' as const,
|
||||
trend: 'down' as const,
|
||||
estimatedCost: 45.60
|
||||
},
|
||||
{
|
||||
type: 'underproofed',
|
||||
count: Math.round(totalDefects * 0.24),
|
||||
percentage: 24,
|
||||
severity: 'medium' as const,
|
||||
trend: 'stable' as const,
|
||||
estimatedCost: 28.90
|
||||
},
|
||||
{
|
||||
type: 'misshapen',
|
||||
count: Math.round(totalDefects * 0.19),
|
||||
percentage: 19,
|
||||
severity: 'medium' as const,
|
||||
trend: 'up' as const,
|
||||
estimatedCost: 22.40
|
||||
},
|
||||
{
|
||||
type: 'color_issues',
|
||||
count: Math.round(totalDefects * 0.15),
|
||||
percentage: 15,
|
||||
severity: 'low' as const,
|
||||
trend: 'stable' as const,
|
||||
estimatedCost: 15.20
|
||||
},
|
||||
{
|
||||
type: 'texture_problems',
|
||||
count: Math.round(totalDefects * 0.10),
|
||||
percentage: 10,
|
||||
severity: 'medium' as const,
|
||||
trend: 'down' as const,
|
||||
estimatedCost: 12.80
|
||||
}
|
||||
].filter(defect => defect.count > 0);
|
||||
|
||||
return defectTypes;
|
||||
};
|
||||
|
||||
const defectData = getDefectData();
|
||||
const totalDefects = defectData.reduce((sum, defect) => sum + defect.count, 0);
|
||||
const totalDefectCost = defectData.reduce((sum, defect) => sum + defect.estimatedCost, 0);
|
||||
|
||||
// Create defect distribution pie chart
|
||||
const getDefectDistributionChartData = (): ChartSeries[] => {
|
||||
if (defectData.length === 0) return [];
|
||||
|
||||
const colors = ['#dc2626', '#ea580c', '#d97706', '#ca8a04', '#65a30d'];
|
||||
const pieData = defectData.map((defect, index) => ({
|
||||
x: t(`quality.defects.${defect.type}`),
|
||||
y: defect.count,
|
||||
label: t(`quality.defects.${defect.type}`),
|
||||
color: colors[index % colors.length]
|
||||
}));
|
||||
|
||||
return [
|
||||
{
|
||||
id: 'defect-distribution',
|
||||
name: t('quality.defect_distribution'),
|
||||
type: 'pie',
|
||||
color: '#dc2626',
|
||||
data: pieData
|
||||
}
|
||||
];
|
||||
};
|
||||
|
||||
// Create defect trend over time
|
||||
const getDefectTrendChartData = (): ChartSeries[] => {
|
||||
if (defectData.length === 0) return [];
|
||||
|
||||
const days = ['Lun', 'Mar', 'Mié', 'Jue', 'Vie', 'Sáb', 'Dom'];
|
||||
|
||||
return defectData.slice(0, 3).map((defect, seriesIndex) => {
|
||||
const colors = ['#dc2626', '#ea580c', '#d97706'];
|
||||
const baseValue = defect.count / 7; // Average per day
|
||||
|
||||
const trendData = days.map((day, index) => {
|
||||
let value = baseValue;
|
||||
// Apply trend
|
||||
if (defect.trend === 'up') value *= (1 + index * 0.05);
|
||||
else if (defect.trend === 'down') value *= (1 - index * 0.05);
|
||||
|
||||
return {
|
||||
x: day,
|
||||
y: Math.max(0, value + (Math.random() - 0.5) * baseValue * 0.3),
|
||||
label: day
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
id: `defect-trend-${defect.type}`,
|
||||
name: t(`quality.defects.${defect.type}`),
|
||||
type: 'line' as const,
|
||||
color: colors[seriesIndex],
|
||||
data: trendData
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
const getSeverityBadgeVariant = (severity: DefectType['severity']) => {
|
||||
switch (severity) {
|
||||
case 'high': return 'error';
|
||||
case 'medium': return 'warning';
|
||||
case 'low': return 'info';
|
||||
default: return 'default';
|
||||
}
|
||||
};
|
||||
|
||||
const getTrendIcon = (trend: DefectType['trend']) => {
|
||||
switch (trend) {
|
||||
case 'up': return '↗️';
|
||||
case 'down': return '↘️';
|
||||
case 'stable': return '➡️';
|
||||
default: return '➡️';
|
||||
}
|
||||
};
|
||||
|
||||
const getTrendColor = (trend: DefectType['trend']) => {
|
||||
switch (trend) {
|
||||
case 'up': return 'text-red-600';
|
||||
case 'down': return 'text-green-600';
|
||||
case 'stable': return 'text-blue-600';
|
||||
default: return 'text-gray-600';
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<AnalyticsWidget
|
||||
title={t('quality.top_defect_types_24h')}
|
||||
subtitle={t('quality.defect_analysis_cost_impact')}
|
||||
icon={AlertCircle}
|
||||
loading={isLoading}
|
||||
error={error?.message}
|
||||
actions={
|
||||
<Button variant="outline" size="sm">
|
||||
<Eye className="w-4 h-4 mr-1" />
|
||||
{t('quality.actions.view_details')}
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
{/* Overall Defect Metrics */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="p-4 bg-[var(--bg-secondary)] rounded-lg text-center">
|
||||
<div className="flex items-center justify-center space-x-2 mb-1">
|
||||
<AlertCircle className="w-5 h-5 text-red-600" />
|
||||
<span className="text-2xl font-bold text-[var(--text-primary)]">
|
||||
{totalDefects}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm text-[var(--text-secondary)]">{t('quality.total_defects')}</p>
|
||||
</div>
|
||||
<div className="p-4 bg-[var(--bg-secondary)] rounded-lg text-center">
|
||||
<div className="flex items-center justify-center space-x-2 mb-1">
|
||||
<span className="text-2xl font-bold text-red-600">
|
||||
€{totalDefectCost.toFixed(0)}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm text-[var(--text-secondary)]">{t('quality.estimated_cost')}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{defectData.length === 0 ? (
|
||||
<div className="text-center py-8 text-[var(--text-secondary)]">
|
||||
<CheckSquare className="w-12 h-12 mx-auto mb-3 opacity-50" />
|
||||
<h3 className="text-lg font-medium text-[var(--text-primary)] mb-2">
|
||||
{t('quality.no_defects_detected')}
|
||||
</h3>
|
||||
<p className="text-sm">{t('quality.excellent_quality_standards')}</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Top Defect Types List */}
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-[var(--text-primary)] mb-3">
|
||||
{t('quality.defect_breakdown')}
|
||||
</h4>
|
||||
<div className="space-y-3 max-h-64 overflow-y-auto">
|
||||
{defectData.map((defect, index) => (
|
||||
<div key={index} className="flex items-center justify-between p-3 bg-[var(--bg-secondary)] rounded-lg">
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="w-8 h-8 bg-red-100 dark:bg-red-900/20 rounded-lg flex items-center justify-center">
|
||||
<span className="text-sm font-bold text-red-600">#{index + 1}</span>
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium text-[var(--text-primary)]">
|
||||
{t(`quality.defects.${defect.type}`)}
|
||||
</p>
|
||||
<div className="flex items-center space-x-3 text-xs text-[var(--text-secondary)]">
|
||||
<span>{defect.count} {t('quality.incidents')}</span>
|
||||
<span>•</span>
|
||||
<span>€{defect.estimatedCost.toFixed(2)} {t('quality.cost')}</span>
|
||||
<span className={getTrendColor(defect.trend)}>
|
||||
{getTrendIcon(defect.trend)} {t(`quality.trend.${defect.trend}`)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<span className="text-lg font-bold text-[var(--text-primary)]">
|
||||
{defect.percentage}%
|
||||
</span>
|
||||
<Badge variant={getSeverityBadgeVariant(defect.severity)}>
|
||||
{t(`quality.severity.${defect.severity}`)}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Defect Distribution Chart */}
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-[var(--text-primary)] mb-3">
|
||||
{t('quality.defect_distribution')}
|
||||
</h4>
|
||||
<AnalyticsChart
|
||||
series={getDefectDistributionChartData()}
|
||||
height={200}
|
||||
showLegend={true}
|
||||
showGrid={false}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Defect Trends Over Time */}
|
||||
{defectData.length > 0 && (
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-[var(--text-primary)] mb-3">
|
||||
{t('quality.top_defects_weekly_trend')}
|
||||
</h4>
|
||||
<AnalyticsChart
|
||||
series={getDefectTrendChartData()}
|
||||
height={150}
|
||||
showLegend={true}
|
||||
showGrid={true}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Defect Prevention Insights */}
|
||||
<div className="space-y-2">
|
||||
{defectData.some(d => d.severity === 'high') && (
|
||||
<div className="flex items-start space-x-2 p-3 bg-red-50 dark:bg-red-900/20 rounded-lg">
|
||||
<AlertCircle className="w-4 h-4 mt-0.5 text-red-600" />
|
||||
<div>
|
||||
<p className="text-sm font-medium text-red-600">
|
||||
{t('quality.recommendations.critical_defects')}
|
||||
</p>
|
||||
<p className="text-xs text-[var(--text-secondary)] mt-1">
|
||||
{t('quality.recommendations.immediate_action_required')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-start space-x-2 p-3 bg-blue-50 dark:bg-blue-900/20 rounded-lg">
|
||||
<Camera className="w-4 h-4 mt-0.5 text-blue-600" />
|
||||
<div>
|
||||
<p className="text-sm font-medium text-blue-600">
|
||||
{t('quality.recommendations.documentation')}
|
||||
</p>
|
||||
<p className="text-xs text-[var(--text-secondary)] mt-1">
|
||||
{t('quality.recommendations.photo_documentation_helps')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Action Items */}
|
||||
<div className="pt-3 border-t border-[var(--border-primary)]">
|
||||
<h5 className="text-sm font-medium text-[var(--text-primary)] mb-2">
|
||||
{t('quality.recommended_actions')}
|
||||
</h5>
|
||||
<div className="space-y-1 text-xs text-[var(--text-secondary)]">
|
||||
{defectData[0] && (
|
||||
<p>• {t('quality.actions.focus_on')} {t(`quality.defects.${defectData[0].type}`).toLowerCase()}</p>
|
||||
)}
|
||||
{defectData.some(d => d.trend === 'up') && (
|
||||
<p>• {t('quality.actions.investigate_increasing_defects')}</p>
|
||||
)}
|
||||
<p>• {t('quality.actions.review_process_controls')}</p>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</AnalyticsWidget>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,323 @@
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { AlertTriangle, Trash2, Target, TrendingDown } from 'lucide-react';
|
||||
import { AnalyticsWidget } from '../AnalyticsWidget';
|
||||
import { AnalyticsChart, ChartSeries } from '../AnalyticsChart';
|
||||
import { Button, Badge } from '../../../ui';
|
||||
import { useActiveBatches } from '../../../../api/hooks/production';
|
||||
import { useCurrentTenant } from '../../../../stores/tenant.store';
|
||||
|
||||
interface WasteSource {
|
||||
source: string;
|
||||
count: number;
|
||||
percentage: number;
|
||||
cost: number;
|
||||
severity: 'low' | 'medium' | 'high';
|
||||
}
|
||||
|
||||
export const WasteDefectTrackerWidget: React.FC = () => {
|
||||
const { t } = useTranslation('production');
|
||||
const currentTenant = useCurrentTenant();
|
||||
const tenantId = currentTenant?.id || '';
|
||||
|
||||
const { data: batchesData, isLoading, error } = useActiveBatches(tenantId);
|
||||
const batches = batchesData?.batches || [];
|
||||
|
||||
// Calculate waste and defect data from batches
|
||||
const getWasteData = () => {
|
||||
const totalUnits = batches.reduce((sum, batch) => sum + (batch.actual_quantity || batch.planned_quantity || 0), 0);
|
||||
const totalWaste = batches.reduce((sum, batch) => sum + (batch.waste_quantity || 0), 0);
|
||||
const totalDefects = batches.reduce((sum, batch) => sum + (batch.defect_quantity || 0), 0);
|
||||
|
||||
const wastePercentage = totalUnits > 0 ? (totalWaste / totalUnits) * 100 : 0;
|
||||
const defectPercentage = totalUnits > 0 ? (totalDefects / totalUnits) * 100 : 0;
|
||||
|
||||
return {
|
||||
totalUnits,
|
||||
totalWaste,
|
||||
totalDefects,
|
||||
wastePercentage,
|
||||
defectPercentage
|
||||
};
|
||||
};
|
||||
|
||||
const wasteData = getWasteData();
|
||||
|
||||
// Mock waste sources data (in real implementation, this would come from quality check defect types)
|
||||
const getWasteSources = (): WasteSource[] => {
|
||||
return [
|
||||
{
|
||||
source: t('quality.defects.burnt'),
|
||||
count: Math.round(wasteData.totalWaste * 0.35),
|
||||
percentage: 35,
|
||||
cost: 45.20,
|
||||
severity: 'high'
|
||||
},
|
||||
{
|
||||
source: t('quality.defects.misshapen'),
|
||||
count: Math.round(wasteData.totalWaste * 0.28),
|
||||
percentage: 28,
|
||||
cost: 32.15,
|
||||
severity: 'medium'
|
||||
},
|
||||
{
|
||||
source: t('quality.defects.underproofed'),
|
||||
count: Math.round(wasteData.totalWaste * 0.20),
|
||||
percentage: 20,
|
||||
cost: 28.90,
|
||||
severity: 'medium'
|
||||
},
|
||||
{
|
||||
source: t('quality.defects.temperature_issues'),
|
||||
count: Math.round(wasteData.totalWaste * 0.12),
|
||||
percentage: 12,
|
||||
cost: 18.70,
|
||||
severity: 'low'
|
||||
},
|
||||
{
|
||||
source: t('quality.defects.expired'),
|
||||
count: Math.round(wasteData.totalWaste * 0.05),
|
||||
percentage: 5,
|
||||
cost: 8.40,
|
||||
severity: 'low'
|
||||
}
|
||||
].filter(source => source.count > 0);
|
||||
};
|
||||
|
||||
const wasteSources = getWasteSources();
|
||||
const totalWasteCost = wasteSources.reduce((sum, source) => sum + source.cost, 0);
|
||||
|
||||
// Create pie chart for waste sources
|
||||
const getWasteSourcesChartData = (): ChartSeries[] => {
|
||||
if (wasteSources.length === 0) return [];
|
||||
|
||||
const colors = ['#dc2626', '#ea580c', '#d97706', '#ca8a04', '#65a30d'];
|
||||
const pieData = wasteSources.map((source, index) => ({
|
||||
x: source.source,
|
||||
y: source.count,
|
||||
label: source.source,
|
||||
color: colors[index % colors.length]
|
||||
}));
|
||||
|
||||
return [
|
||||
{
|
||||
id: 'waste-sources',
|
||||
name: t('quality.waste_sources'),
|
||||
type: 'pie',
|
||||
color: '#dc2626',
|
||||
data: pieData
|
||||
}
|
||||
];
|
||||
};
|
||||
|
||||
// Create trend chart for waste over time
|
||||
const getWasteTrendChartData = (): ChartSeries[] => {
|
||||
const days = ['Lun', 'Mar', 'Mié', 'Jue', 'Vie', 'Sáb', 'Dom'];
|
||||
const wasteData = days.map((day, index) => ({
|
||||
x: day,
|
||||
y: Math.max(1, 8 - index + Math.random() * 3), // Decreasing trend with some variation
|
||||
label: day
|
||||
}));
|
||||
|
||||
const defectData = days.map((day, index) => ({
|
||||
x: day,
|
||||
y: Math.max(0.5, 5 - index * 0.5 + Math.random() * 2),
|
||||
label: day
|
||||
}));
|
||||
|
||||
return [
|
||||
{
|
||||
id: 'waste-trend',
|
||||
name: t('quality.waste_percentage'),
|
||||
type: 'line',
|
||||
color: '#dc2626',
|
||||
data: wasteData
|
||||
},
|
||||
{
|
||||
id: 'defect-trend',
|
||||
name: t('quality.defect_percentage'),
|
||||
type: 'line',
|
||||
color: '#ea580c',
|
||||
data: defectData
|
||||
}
|
||||
];
|
||||
};
|
||||
|
||||
const getSeverityBadgeVariant = (severity: WasteSource['severity']) => {
|
||||
switch (severity) {
|
||||
case 'high': return 'error';
|
||||
case 'medium': return 'warning';
|
||||
case 'low': return 'info';
|
||||
default: return 'default';
|
||||
}
|
||||
};
|
||||
|
||||
const getWasteStatus = () => {
|
||||
const totalWastePercentage = wasteData.wastePercentage + wasteData.defectPercentage;
|
||||
if (totalWastePercentage <= 3) return { status: 'excellent', color: 'text-green-600', bgColor: 'bg-green-100 dark:bg-green-900/20' };
|
||||
if (totalWastePercentage <= 5) return { status: 'good', color: 'text-blue-600', bgColor: 'bg-blue-100 dark:bg-blue-900/20' };
|
||||
if (totalWastePercentage <= 8) return { status: 'warning', color: 'text-orange-600', bgColor: 'bg-orange-100 dark:bg-orange-900/20' };
|
||||
return { status: 'critical', color: 'text-red-600', bgColor: 'bg-red-100 dark:bg-red-900/20' };
|
||||
};
|
||||
|
||||
const wasteStatus = getWasteStatus();
|
||||
const totalWastePercentage = wasteData.wastePercentage + wasteData.defectPercentage;
|
||||
|
||||
return (
|
||||
<AnalyticsWidget
|
||||
title={t('quality.waste_defect_tracker')}
|
||||
subtitle={t('quality.waste_sources_trends_costs')}
|
||||
icon={AlertTriangle}
|
||||
loading={isLoading}
|
||||
error={error?.message}
|
||||
actions={
|
||||
<Button variant="outline" size="sm">
|
||||
<Target className="w-4 h-4 mr-1" />
|
||||
{t('quality.actions.reduce_waste')}
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
{/* Overall Waste Metrics */}
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div className="p-3 bg-[var(--bg-secondary)] rounded-lg text-center">
|
||||
<div className="flex items-center justify-center space-x-2 mb-1">
|
||||
<Trash2 className="w-4 h-4 text-red-600" />
|
||||
<span className="text-lg font-bold text-[var(--text-primary)]">
|
||||
{totalWastePercentage.toFixed(1)}%
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs text-[var(--text-secondary)]">{t('quality.total_waste')}</p>
|
||||
</div>
|
||||
<div className="p-3 bg-[var(--bg-secondary)] rounded-lg text-center">
|
||||
<div className="flex items-center justify-center space-x-2 mb-1">
|
||||
<AlertTriangle className="w-4 h-4 text-orange-600" />
|
||||
<span className="text-lg font-bold text-[var(--text-primary)]">
|
||||
{wasteData.totalDefects}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs text-[var(--text-secondary)]">{t('quality.total_defects')}</p>
|
||||
</div>
|
||||
<div className="p-3 bg-[var(--bg-secondary)] rounded-lg text-center">
|
||||
<div className="flex items-center justify-center space-x-2 mb-1">
|
||||
<TrendingDown className="w-4 h-4 text-green-600" />
|
||||
<span className="text-lg font-bold text-[var(--text-primary)]">
|
||||
€{totalWasteCost.toFixed(0)}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs text-[var(--text-secondary)]">{t('cost.waste_cost')}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Waste Status */}
|
||||
<div className={`p-3 rounded-lg ${wasteStatus.bgColor}`}>
|
||||
<div className="flex items-center space-x-2">
|
||||
<AlertTriangle className={`w-4 h-4 ${wasteStatus.color}`} />
|
||||
<span className={`text-sm font-medium ${wasteStatus.color}`}>
|
||||
{t(`quality.status.${wasteStatus.status}`)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{wasteSources.length === 0 ? (
|
||||
<div className="text-center py-8 text-[var(--text-secondary)]">
|
||||
<Target className="w-12 h-12 mx-auto mb-3 opacity-50" />
|
||||
<p className="text-sm">{t('quality.no_waste_data')}</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Waste Sources Breakdown */}
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-[var(--text-primary)] mb-3">
|
||||
{t('quality.top_waste_sources')}
|
||||
</h4>
|
||||
<div className="space-y-2">
|
||||
{wasteSources.slice(0, 5).map((source, index) => (
|
||||
<div key={index} className="flex items-center justify-between p-3 bg-[var(--bg-secondary)] rounded-lg">
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="w-3 h-3 bg-red-500 rounded-full" />
|
||||
<div>
|
||||
<p className="text-sm font-medium text-[var(--text-primary)]">
|
||||
{source.source}
|
||||
</p>
|
||||
<p className="text-xs text-[var(--text-secondary)]">
|
||||
{source.count} {t('common.units')} • €{source.cost.toFixed(2)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<span className="text-sm font-semibold text-[var(--text-primary)]">
|
||||
{source.percentage}%
|
||||
</span>
|
||||
<Badge variant={getSeverityBadgeVariant(source.severity)}>
|
||||
{t(`quality.severity.${source.severity}`)}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Waste Sources Pie Chart */}
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-[var(--text-primary)] mb-3">
|
||||
{t('quality.waste_distribution')}
|
||||
</h4>
|
||||
<AnalyticsChart
|
||||
series={getWasteSourcesChartData()}
|
||||
height={200}
|
||||
showLegend={true}
|
||||
showGrid={false}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Weekly Waste Trend */}
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-[var(--text-primary)] mb-3">
|
||||
{t('quality.weekly_waste_trend')}
|
||||
</h4>
|
||||
<AnalyticsChart
|
||||
series={getWasteTrendChartData()}
|
||||
height={150}
|
||||
showLegend={true}
|
||||
showGrid={true}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Reduction Recommendations */}
|
||||
<div className="space-y-2">
|
||||
{wasteSources.filter(s => s.severity === 'high').length > 0 && (
|
||||
<div className="flex items-start space-x-2 p-3 bg-red-50 dark:bg-red-900/20 rounded-lg">
|
||||
<AlertTriangle className="w-4 h-4 mt-0.5 text-red-600" />
|
||||
<div>
|
||||
<p className="text-sm font-medium text-red-600">
|
||||
{t('quality.recommendations.high_waste_detected')}
|
||||
</p>
|
||||
<p className="text-xs text-[var(--text-secondary)] mt-1">
|
||||
{t('quality.recommendations.check_temperature_timing')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-start space-x-2 p-3 bg-blue-50 dark:bg-blue-900/20 rounded-lg">
|
||||
<Target className="w-4 h-4 mt-0.5 text-blue-600" />
|
||||
<div>
|
||||
<p className="text-sm font-medium text-blue-600">
|
||||
{t('quality.recommendations.improvement_opportunity')}
|
||||
</p>
|
||||
<p className="text-xs text-[var(--text-secondary)] mt-1">
|
||||
{totalWastePercentage > 5
|
||||
? t('quality.recommendations.reduce_waste_target', { target: '3%' })
|
||||
: t('quality.recommendations.maintain_quality_standards')
|
||||
}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</AnalyticsWidget>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,321 @@
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Target, TrendingUp, Award, BarChart3 } from 'lucide-react';
|
||||
import { AnalyticsWidget } from '../AnalyticsWidget';
|
||||
import { AnalyticsChart, ChartSeries } from '../AnalyticsChart';
|
||||
import { Button, Badge } from '../../../ui';
|
||||
import { useActiveBatches } from '../../../../api/hooks/production';
|
||||
import { useCurrentTenant } from '../../../../stores/tenant.store';
|
||||
|
||||
interface ProductYieldData {
|
||||
product: string;
|
||||
averageYield: number;
|
||||
bestYield: number;
|
||||
worstYield: number;
|
||||
batchCount: number;
|
||||
trend: 'up' | 'down' | 'stable';
|
||||
performance: 'excellent' | 'good' | 'average' | 'poor';
|
||||
}
|
||||
|
||||
export const YieldPerformanceWidget: React.FC = () => {
|
||||
const { t } = useTranslation('production');
|
||||
const currentTenant = useCurrentTenant();
|
||||
const tenantId = currentTenant?.id || '';
|
||||
|
||||
const { data: batchesData, isLoading, error } = useActiveBatches(tenantId);
|
||||
const batches = batchesData?.batches || [];
|
||||
|
||||
// Calculate yield data from batches
|
||||
const getProductYieldData = (): ProductYieldData[] => {
|
||||
const productMap = new Map<string, number[]>();
|
||||
|
||||
batches.forEach(batch => {
|
||||
if (batch.yield_percentage) {
|
||||
const product = batch.product_name;
|
||||
if (!productMap.has(product)) {
|
||||
productMap.set(product, []);
|
||||
}
|
||||
productMap.get(product)!.push(batch.yield_percentage);
|
||||
}
|
||||
});
|
||||
|
||||
return Array.from(productMap.entries()).map(([product, yields]) => {
|
||||
const averageYield = yields.reduce((sum, yieldValue) => sum + yieldValue, 0) / yields.length;
|
||||
const bestYield = Math.max(...yields);
|
||||
const worstYield = Math.min(...yields);
|
||||
|
||||
// Simulate trend calculation (in real implementation, this would compare with historical data)
|
||||
const trend: 'up' | 'down' | 'stable' =
|
||||
averageYield > 92 ? 'up' :
|
||||
averageYield < 88 ? 'down' : 'stable';
|
||||
|
||||
const performance: 'excellent' | 'good' | 'average' | 'poor' =
|
||||
averageYield >= 95 ? 'excellent' :
|
||||
averageYield >= 90 ? 'good' :
|
||||
averageYield >= 85 ? 'average' : 'poor';
|
||||
|
||||
return {
|
||||
product,
|
||||
averageYield,
|
||||
bestYield,
|
||||
worstYield,
|
||||
batchCount: yields.length,
|
||||
trend,
|
||||
performance
|
||||
};
|
||||
}).sort((a, b) => b.averageYield - a.averageYield);
|
||||
};
|
||||
|
||||
const productYieldData = getProductYieldData();
|
||||
const overallYield = productYieldData.length > 0
|
||||
? productYieldData.reduce((sum, item) => sum + item.averageYield, 0) / productYieldData.length
|
||||
: 0;
|
||||
|
||||
// Create yield comparison chart
|
||||
const getYieldComparisonChartData = (): ChartSeries[] => {
|
||||
if (productYieldData.length === 0) return [];
|
||||
|
||||
const averageData = productYieldData.map(item => ({
|
||||
x: item.product,
|
||||
y: item.averageYield,
|
||||
label: item.product
|
||||
}));
|
||||
|
||||
const bestData = productYieldData.map(item => ({
|
||||
x: item.product,
|
||||
y: item.bestYield,
|
||||
label: item.product
|
||||
}));
|
||||
|
||||
return [
|
||||
{
|
||||
id: 'average-yield',
|
||||
name: t('stats.average_yield'),
|
||||
type: 'bar',
|
||||
color: '#d97706',
|
||||
data: averageData
|
||||
},
|
||||
{
|
||||
id: 'best-yield',
|
||||
name: t('stats.best_yield'),
|
||||
type: 'bar',
|
||||
color: '#16a34a',
|
||||
data: bestData
|
||||
}
|
||||
];
|
||||
};
|
||||
|
||||
// Create yield trend over time
|
||||
const getYieldTrendChartData = (): ChartSeries[] => {
|
||||
const days = ['Lun', 'Mar', 'Mié', 'Jue', 'Vie', 'Sáb', 'Dom'];
|
||||
const trendData = days.map((day, index) => ({
|
||||
x: day,
|
||||
y: Math.max(85, 90 + index * 0.5 + Math.random() * 3), // Slightly improving trend
|
||||
label: day
|
||||
}));
|
||||
|
||||
return [
|
||||
{
|
||||
id: 'yield-trend',
|
||||
name: t('stats.yield_trend'),
|
||||
type: 'line',
|
||||
color: '#16a34a',
|
||||
data: trendData
|
||||
}
|
||||
];
|
||||
};
|
||||
|
||||
const getPerformanceBadgeVariant = (performance: ProductYieldData['performance']) => {
|
||||
switch (performance) {
|
||||
case 'excellent': return 'success';
|
||||
case 'good': return 'info';
|
||||
case 'average': return 'warning';
|
||||
case 'poor': return 'error';
|
||||
default: return 'default';
|
||||
}
|
||||
};
|
||||
|
||||
const getTrendIcon = (trend: ProductYieldData['trend']) => {
|
||||
switch (trend) {
|
||||
case 'up': return TrendingUp;
|
||||
case 'down': return TrendingUp; // We'll rotate it in CSS for down
|
||||
case 'stable': return BarChart3;
|
||||
default: return BarChart3;
|
||||
}
|
||||
};
|
||||
|
||||
const getTrendColor = (trend: ProductYieldData['trend']) => {
|
||||
switch (trend) {
|
||||
case 'up': return 'text-green-600';
|
||||
case 'down': return 'text-red-600';
|
||||
case 'stable': return 'text-blue-600';
|
||||
default: return 'text-gray-600';
|
||||
}
|
||||
};
|
||||
|
||||
const getYieldStatus = () => {
|
||||
if (overallYield >= 95) return { status: 'excellent', color: 'text-green-600', bgColor: 'bg-green-100 dark:bg-green-900/20' };
|
||||
if (overallYield >= 90) return { status: 'good', color: 'text-blue-600', bgColor: 'bg-blue-100 dark:bg-blue-900/20' };
|
||||
if (overallYield >= 85) return { status: 'average', color: 'text-orange-600', bgColor: 'bg-orange-100 dark:bg-orange-900/20' };
|
||||
return { status: 'poor', color: 'text-red-600', bgColor: 'bg-red-100 dark:bg-red-900/20' };
|
||||
};
|
||||
|
||||
const yieldStatus = getYieldStatus();
|
||||
|
||||
return (
|
||||
<AnalyticsWidget
|
||||
title={t('stats.yield_performance_leaderboard')}
|
||||
subtitle={t('stats.product_yield_rankings_trends')}
|
||||
icon={Award}
|
||||
loading={isLoading}
|
||||
error={error?.message}
|
||||
actions={
|
||||
<Button variant="outline" size="sm">
|
||||
<Target className="w-4 h-4 mr-1" />
|
||||
{t('actions.optimize_yields')}
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
{/* Overall Yield Status */}
|
||||
<div className="text-center">
|
||||
<div className="flex items-center justify-center space-x-2 mb-2">
|
||||
<Award className={`w-6 h-6 ${yieldStatus.color}`} />
|
||||
<span className="text-3xl font-bold text-[var(--text-primary)]">
|
||||
{overallYield.toFixed(1)}%
|
||||
</span>
|
||||
</div>
|
||||
<div className={`inline-flex items-center space-x-2 px-3 py-1 rounded-full text-sm ${yieldStatus.bgColor}`}>
|
||||
<span className={yieldStatus.color}>
|
||||
{t(`performance.${yieldStatus.status}`)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{productYieldData.length === 0 ? (
|
||||
<div className="text-center py-8 text-[var(--text-secondary)]">
|
||||
<Award className="w-12 h-12 mx-auto mb-3 opacity-50" />
|
||||
<p className="text-sm">{t('stats.no_yield_data')}</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Yield Leaderboard */}
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-[var(--text-primary)] mb-3 flex items-center">
|
||||
<Award className="w-4 h-4 mr-2" />
|
||||
{t('stats.product_leaderboard')}
|
||||
</h4>
|
||||
<div className="space-y-2 max-h-64 overflow-y-auto">
|
||||
{productYieldData.map((item, index) => {
|
||||
const TrendIcon = getTrendIcon(item.trend);
|
||||
const trendColor = getTrendColor(item.trend);
|
||||
|
||||
return (
|
||||
<div key={index} className="flex items-center justify-between p-3 bg-[var(--bg-secondary)] rounded-lg">
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="flex items-center justify-center w-6 h-6 bg-[var(--color-primary)]/10 rounded-full text-xs font-bold text-[var(--color-primary)]">
|
||||
{index + 1}
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium text-[var(--text-primary)]">
|
||||
{item.product}
|
||||
</p>
|
||||
<div className="flex items-center space-x-2 text-xs text-[var(--text-secondary)]">
|
||||
<span>{item.batchCount} {t('stats.batches')}</span>
|
||||
<span>•</span>
|
||||
<span>{item.bestYield.toFixed(1)}% {t('stats.best')}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className={`flex items-center space-x-1 ${trendColor}`}>
|
||||
<TrendIcon
|
||||
className={`w-3 h-3 ${item.trend === 'down' ? 'transform rotate-180' : ''}`}
|
||||
/>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<div className="text-lg font-bold text-[var(--text-primary)]">
|
||||
{item.averageYield.toFixed(1)}%
|
||||
</div>
|
||||
<Badge variant={getPerformanceBadgeVariant(item.performance)} className="text-xs">
|
||||
{t(`performance.${item.performance}`)}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Yield Comparison Chart */}
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-[var(--text-primary)] mb-3">
|
||||
{t('stats.yield_comparison')}
|
||||
</h4>
|
||||
<AnalyticsChart
|
||||
series={getYieldComparisonChartData()}
|
||||
height={200}
|
||||
showLegend={true}
|
||||
showGrid={true}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Weekly Yield Trend */}
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-[var(--text-primary)] mb-3">
|
||||
{t('stats.weekly_yield_trend')}
|
||||
</h4>
|
||||
<AnalyticsChart
|
||||
series={getYieldTrendChartData()}
|
||||
height={150}
|
||||
showLegend={false}
|
||||
showGrid={true}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Yield Insights */}
|
||||
<div className="grid grid-cols-3 gap-3 text-center">
|
||||
<div className="p-3 bg-[var(--bg-secondary)] rounded-lg">
|
||||
<p className="text-lg font-bold text-green-600">
|
||||
{productYieldData.filter(p => p.performance === 'excellent').length}
|
||||
</p>
|
||||
<p className="text-xs text-[var(--text-secondary)]">{t('performance.excellent')}</p>
|
||||
</div>
|
||||
<div className="p-3 bg-[var(--bg-secondary)] rounded-lg">
|
||||
<p className="text-lg font-bold text-blue-600">
|
||||
{productYieldData.filter(p => p.performance === 'good').length}
|
||||
</p>
|
||||
<p className="text-xs text-[var(--text-secondary)]">{t('performance.good')}</p>
|
||||
</div>
|
||||
<div className="p-3 bg-[var(--bg-secondary)] rounded-lg">
|
||||
<p className="text-lg font-bold text-orange-600">
|
||||
{productYieldData.filter(p => p.performance === 'average' || p.performance === 'poor').length}
|
||||
</p>
|
||||
<p className="text-xs text-[var(--text-secondary)]">{t('performance.needs_improvement')}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Improvement Recommendations */}
|
||||
<div className="p-3 bg-blue-50 dark:bg-blue-900/20 rounded-lg">
|
||||
<div className="flex items-start space-x-2">
|
||||
<Target className="w-4 h-4 mt-0.5 text-blue-600" />
|
||||
<div>
|
||||
<p className="text-sm font-medium text-blue-600">
|
||||
{t('recommendations.yield_improvement')}
|
||||
</p>
|
||||
<p className="text-xs text-[var(--text-secondary)] mt-1">
|
||||
{overallYield < 90
|
||||
? t('recommendations.focus_on_low_performers')
|
||||
: t('recommendations.maintain_high_standards')
|
||||
}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</AnalyticsWidget>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,38 @@
|
||||
// Bakery Operations Overview Widgets
|
||||
export { TodaysScheduleSummaryWidget } from './TodaysScheduleSummaryWidget';
|
||||
export { LiveBatchTrackerWidget } from './LiveBatchTrackerWidget';
|
||||
export { OnTimeCompletionWidget } from './OnTimeCompletionWidget';
|
||||
export { CapacityUtilizationWidget } from './CapacityUtilizationWidget';
|
||||
|
||||
// Cost & Efficiency Monitoring Widgets
|
||||
export { CostPerUnitWidget } from './CostPerUnitWidget';
|
||||
export { WasteDefectTrackerWidget } from './WasteDefectTrackerWidget';
|
||||
export { YieldPerformanceWidget } from './YieldPerformanceWidget';
|
||||
|
||||
// Quality Assurance Panel Widgets
|
||||
export { QualityScoreTrendsWidget } from './QualityScoreTrendsWidget';
|
||||
export { TopDefectTypesWidget } from './TopDefectTypesWidget';
|
||||
|
||||
// Equipment & Maintenance Widgets
|
||||
export { EquipmentStatusWidget } from './EquipmentStatusWidget';
|
||||
export { MaintenanceScheduleWidget } from './MaintenanceScheduleWidget';
|
||||
export { EquipmentEfficiencyWidget } from './EquipmentEfficiencyWidget';
|
||||
|
||||
// AI Insights & Predictive Analytics Widgets
|
||||
export { AIInsightsWidget } from './AIInsightsWidget';
|
||||
export { PredictiveMaintenanceWidget } from './PredictiveMaintenanceWidget';
|
||||
|
||||
import React from 'react';
|
||||
|
||||
// Widget Types
|
||||
export interface WidgetConfig {
|
||||
id: string;
|
||||
title: string;
|
||||
component: React.ComponentType;
|
||||
category: 'operations' | 'cost' | 'quality' | 'equipment' | 'ai';
|
||||
size: 'sm' | 'md' | 'lg' | 'xl';
|
||||
minSubscription?: 'basic' | 'professional' | 'enterprise';
|
||||
}
|
||||
|
||||
// Export widget configurations (temporarily commented out due to import issues)
|
||||
// export const WIDGET_CONFIGS: WidgetConfig[] = [];
|
||||
@@ -1,680 +0,0 @@
|
||||
import React, { useState, useCallback, useEffect } from 'react';
|
||||
import { Card, Button, Badge, Input, Modal, Table, Select, DatePicker } from '../../ui';
|
||||
import { productionService, type ProductionBatchResponse, ProductionBatchStatus, ProductionPriorityEnum } from '../../../api';
|
||||
import type { ProductionBatch, QualityCheck } from '../../../types/production.types';
|
||||
|
||||
interface BatchTrackerProps {
|
||||
className?: string;
|
||||
batchId?: string;
|
||||
onStageUpdate?: (batch: ProductionBatch, newStage: ProductionStage) => void;
|
||||
onQualityCheckRequired?: (batch: ProductionBatch, stage: ProductionStage) => void;
|
||||
}
|
||||
|
||||
interface ProductionStage {
|
||||
id: string;
|
||||
name: string;
|
||||
spanishName: string;
|
||||
icon: string;
|
||||
estimatedMinutes: number;
|
||||
requiresQualityCheck: boolean;
|
||||
criticalControlPoint: boolean;
|
||||
completedAt?: string;
|
||||
notes?: string;
|
||||
nextStages: string[];
|
||||
temperature?: {
|
||||
min: number;
|
||||
max: number;
|
||||
unit: string;
|
||||
};
|
||||
}
|
||||
|
||||
const PRODUCTION_STAGES: Record<string, ProductionStage> = {
|
||||
mixing: {
|
||||
id: 'mixing',
|
||||
name: 'Mixing',
|
||||
spanishName: 'Amasado',
|
||||
icon: '🥄',
|
||||
estimatedMinutes: 15,
|
||||
requiresQualityCheck: true,
|
||||
criticalControlPoint: true,
|
||||
nextStages: ['resting'],
|
||||
temperature: { min: 22, max: 26, unit: '°C' },
|
||||
},
|
||||
resting: {
|
||||
id: 'resting',
|
||||
name: 'Resting',
|
||||
spanishName: 'Reposo',
|
||||
icon: '⏰',
|
||||
estimatedMinutes: 60,
|
||||
requiresQualityCheck: false,
|
||||
criticalControlPoint: false,
|
||||
nextStages: ['shaping', 'fermentation'],
|
||||
},
|
||||
shaping: {
|
||||
id: 'shaping',
|
||||
name: 'Shaping',
|
||||
spanishName: 'Formado',
|
||||
icon: '✋',
|
||||
estimatedMinutes: 20,
|
||||
requiresQualityCheck: true,
|
||||
criticalControlPoint: false,
|
||||
nextStages: ['fermentation'],
|
||||
},
|
||||
fermentation: {
|
||||
id: 'fermentation',
|
||||
name: 'Fermentation',
|
||||
spanishName: 'Fermentado',
|
||||
icon: '🫧',
|
||||
estimatedMinutes: 120,
|
||||
requiresQualityCheck: true,
|
||||
criticalControlPoint: true,
|
||||
nextStages: ['baking'],
|
||||
temperature: { min: 28, max: 32, unit: '°C' },
|
||||
},
|
||||
baking: {
|
||||
id: 'baking',
|
||||
name: 'Baking',
|
||||
spanishName: 'Horneado',
|
||||
icon: '🔥',
|
||||
estimatedMinutes: 45,
|
||||
requiresQualityCheck: true,
|
||||
criticalControlPoint: true,
|
||||
nextStages: ['cooling'],
|
||||
temperature: { min: 180, max: 220, unit: '°C' },
|
||||
},
|
||||
cooling: {
|
||||
id: 'cooling',
|
||||
name: 'Cooling',
|
||||
spanishName: 'Enfriado',
|
||||
icon: '❄️',
|
||||
estimatedMinutes: 90,
|
||||
requiresQualityCheck: false,
|
||||
criticalControlPoint: false,
|
||||
nextStages: ['packaging'],
|
||||
temperature: { min: 18, max: 25, unit: '°C' },
|
||||
},
|
||||
packaging: {
|
||||
id: 'packaging',
|
||||
name: 'Packaging',
|
||||
spanishName: 'Empaquetado',
|
||||
icon: '📦',
|
||||
estimatedMinutes: 15,
|
||||
requiresQualityCheck: true,
|
||||
criticalControlPoint: false,
|
||||
nextStages: ['completed'],
|
||||
},
|
||||
completed: {
|
||||
id: 'completed',
|
||||
name: 'Completed',
|
||||
spanishName: 'Completado',
|
||||
icon: '✅',
|
||||
estimatedMinutes: 0,
|
||||
requiresQualityCheck: false,
|
||||
criticalControlPoint: false,
|
||||
nextStages: [],
|
||||
},
|
||||
};
|
||||
|
||||
const STATUS_COLORS = {
|
||||
[ProductionBatchStatus.PLANNED]: 'bg-[var(--color-info)]/10 text-[var(--color-info)] border-[var(--color-info)]/20',
|
||||
[ProductionBatchStatus.IN_PROGRESS]: 'bg-yellow-100 text-yellow-800 border-yellow-200',
|
||||
[ProductionBatchStatus.COMPLETED]: 'bg-[var(--color-success)]/10 text-[var(--color-success)] border-green-200',
|
||||
[ProductionBatchStatus.CANCELLED]: 'bg-[var(--color-error)]/10 text-[var(--color-error)] border-red-200',
|
||||
[ProductionBatchStatus.ON_HOLD]: 'bg-[var(--bg-tertiary)] text-[var(--text-primary)] border-[var(--border-primary)]',
|
||||
};
|
||||
|
||||
const PRIORITY_COLORS = {
|
||||
[ProductionPriorityEnum.LOW]: 'bg-[var(--bg-tertiary)] text-[var(--text-primary)]',
|
||||
[ProductionPriorityEnum.NORMAL]: 'bg-[var(--color-info)]/10 text-[var(--color-info)]',
|
||||
[ProductionPriorityEnum.HIGH]: 'bg-[var(--color-primary)]/10 text-[var(--color-primary)]',
|
||||
[ProductionPriorityEnum.URGENT]: 'bg-[var(--color-error)]/10 text-[var(--color-error)]',
|
||||
};
|
||||
|
||||
export const BatchTracker: React.FC<BatchTrackerProps> = ({
|
||||
className = '',
|
||||
batchId,
|
||||
onStageUpdate,
|
||||
onQualityCheckRequired,
|
||||
}) => {
|
||||
const [batches, setBatches] = useState<ProductionBatchResponse[]>([]);
|
||||
const [selectedBatch, setSelectedBatch] = useState<ProductionBatchResponse | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [currentStage, setCurrentStage] = useState<string>('mixing');
|
||||
const [stageNotes, setStageNotes] = useState<Record<string, string>>({});
|
||||
const [isStageModalOpen, setIsStageModalOpen] = useState(false);
|
||||
const [selectedStageForUpdate, setSelectedStageForUpdate] = useState<ProductionStage | null>(null);
|
||||
const [alerts, setAlerts] = useState<Array<{
|
||||
id: string;
|
||||
batchId: string;
|
||||
stage: string;
|
||||
type: 'overdue' | 'temperature' | 'quality' | 'equipment';
|
||||
message: string;
|
||||
severity: 'low' | 'medium' | 'high';
|
||||
timestamp: string;
|
||||
}>>([]);
|
||||
|
||||
const loadBatches = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await productionService.getProductionBatches({
|
||||
status: ProductionBatchStatus.IN_PROGRESS,
|
||||
});
|
||||
|
||||
if (response.success && response.data) {
|
||||
setBatches(response.data.items || []);
|
||||
|
||||
if (batchId) {
|
||||
const specificBatch = response.data.items.find(b => b.id === batchId);
|
||||
if (specificBatch) {
|
||||
setSelectedBatch(specificBatch);
|
||||
}
|
||||
} else if (response.data.items.length > 0) {
|
||||
setSelectedBatch(response.data.items[0]);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading batches:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [batchId]);
|
||||
|
||||
useEffect(() => {
|
||||
loadBatches();
|
||||
|
||||
// Mock alerts for demonstration
|
||||
setAlerts([
|
||||
{
|
||||
id: '1',
|
||||
batchId: 'batch-1',
|
||||
stage: 'fermentation',
|
||||
type: 'overdue',
|
||||
message: 'El fermentado ha superado el tiempo estimado en 30 minutos',
|
||||
severity: 'medium',
|
||||
timestamp: new Date().toISOString(),
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
batchId: 'batch-2',
|
||||
stage: 'baking',
|
||||
type: 'temperature',
|
||||
message: 'Temperatura del horno fuera del rango óptimo (185°C)',
|
||||
severity: 'high',
|
||||
timestamp: new Date().toISOString(),
|
||||
},
|
||||
]);
|
||||
}, [loadBatches]);
|
||||
|
||||
const getCurrentStageInfo = (batch: ProductionBatchResponse): { stage: ProductionStage; progress: number } => {
|
||||
// This would typically come from the batch data
|
||||
// For demo purposes, we'll simulate based on batch status
|
||||
let stageId = 'mixing';
|
||||
let progress = 0;
|
||||
|
||||
if (batch.status === ProductionBatchStatus.IN_PROGRESS) {
|
||||
// Simulate current stage based on time elapsed
|
||||
const startTime = new Date(batch.actual_start_date || batch.planned_start_date);
|
||||
const now = new Date();
|
||||
const elapsedMinutes = (now.getTime() - startTime.getTime()) / (1000 * 60);
|
||||
|
||||
let cumulativeTime = 0;
|
||||
const stageKeys = Object.keys(PRODUCTION_STAGES).slice(0, -1); // Exclude completed
|
||||
|
||||
for (const key of stageKeys) {
|
||||
const stage = PRODUCTION_STAGES[key];
|
||||
cumulativeTime += stage.estimatedMinutes;
|
||||
if (elapsedMinutes <= cumulativeTime) {
|
||||
stageId = key;
|
||||
const stageStartTime = cumulativeTime - stage.estimatedMinutes;
|
||||
progress = Math.min(100, ((elapsedMinutes - stageStartTime) / stage.estimatedMinutes) * 100);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
stage: PRODUCTION_STAGES[stageId],
|
||||
progress: Math.max(0, progress),
|
||||
};
|
||||
};
|
||||
|
||||
const getTimeRemaining = (batch: ProductionBatchResponse, stage: ProductionStage): string => {
|
||||
const startTime = new Date(batch.actual_start_date || batch.planned_start_date);
|
||||
const now = new Date();
|
||||
const elapsedMinutes = (now.getTime() - startTime.getTime()) / (1000 * 60);
|
||||
|
||||
// Calculate when this stage should complete based on cumulative time
|
||||
let cumulativeTime = 0;
|
||||
const stageKeys = Object.keys(PRODUCTION_STAGES);
|
||||
const currentStageIndex = stageKeys.indexOf(stage.id);
|
||||
|
||||
for (let i = 0; i <= currentStageIndex; i++) {
|
||||
cumulativeTime += PRODUCTION_STAGES[stageKeys[i]].estimatedMinutes;
|
||||
}
|
||||
|
||||
const remainingMinutes = Math.max(0, cumulativeTime - elapsedMinutes);
|
||||
const hours = Math.floor(remainingMinutes / 60);
|
||||
const minutes = Math.floor(remainingMinutes % 60);
|
||||
|
||||
if (hours > 0) {
|
||||
return `${hours}h ${minutes}m restantes`;
|
||||
} else {
|
||||
return `${minutes}m restantes`;
|
||||
}
|
||||
};
|
||||
|
||||
const updateStage = async (batchId: string, newStage: string, notes?: string) => {
|
||||
try {
|
||||
// This would update the batch stage in the backend
|
||||
const updatedBatch = batches.find(b => b.id === batchId);
|
||||
if (updatedBatch && onStageUpdate) {
|
||||
onStageUpdate(updatedBatch as unknown as ProductionBatch, PRODUCTION_STAGES[newStage]);
|
||||
}
|
||||
|
||||
// Update local state
|
||||
if (notes) {
|
||||
setStageNotes(prev => ({
|
||||
...prev,
|
||||
[`${batchId}-${newStage}`]: notes,
|
||||
}));
|
||||
}
|
||||
|
||||
await loadBatches();
|
||||
} catch (error) {
|
||||
console.error('Error updating stage:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleQualityCheck = (batch: ProductionBatchResponse, stage: ProductionStage) => {
|
||||
if (onQualityCheckRequired) {
|
||||
onQualityCheckRequired(batch as unknown as ProductionBatch, stage);
|
||||
}
|
||||
};
|
||||
|
||||
const renderStageProgress = (batch: ProductionBatchResponse) => {
|
||||
const { stage: currentStage, progress } = getCurrentStageInfo(batch);
|
||||
const stages = Object.values(PRODUCTION_STAGES).slice(0, -1); // Exclude completed
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="font-semibold text-lg">Progreso del lote</h3>
|
||||
<Badge className={STATUS_COLORS[batch.status]}>
|
||||
{batch.status === ProductionBatchStatus.IN_PROGRESS && 'En progreso'}
|
||||
{batch.status === ProductionBatchStatus.PLANNED && 'Planificado'}
|
||||
{batch.status === ProductionBatchStatus.COMPLETED && 'Completado'}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
{stages.map((stage, index) => {
|
||||
const isActive = stage.id === currentStage.id;
|
||||
const isCompleted = stages.findIndex(s => s.id === currentStage.id) > index;
|
||||
const isOverdue = false; // Would be calculated based on actual timing
|
||||
|
||||
return (
|
||||
<Card
|
||||
key={stage.id}
|
||||
className={`p-4 border-2 transition-all cursor-pointer hover:shadow-md ${
|
||||
isActive
|
||||
? 'border-blue-500 bg-[var(--color-info)]/5'
|
||||
: isCompleted
|
||||
? 'border-green-500 bg-green-50'
|
||||
: 'border-[var(--border-primary)] bg-white'
|
||||
} ${isOverdue ? 'border-red-500 bg-red-50' : ''}`}
|
||||
onClick={() => {
|
||||
setSelectedStageForUpdate(stage);
|
||||
setIsStageModalOpen(true);
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xl">{stage.icon}</span>
|
||||
<span className="font-medium text-sm">{stage.spanishName}</span>
|
||||
</div>
|
||||
{stage.criticalControlPoint && (
|
||||
<Badge className="bg-[var(--color-primary)]/10 text-[var(--color-primary)] text-xs">
|
||||
PCC
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isActive && (
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between text-xs text-[var(--text-secondary)]">
|
||||
<span>Progreso</span>
|
||||
<span>{Math.round(progress)}%</span>
|
||||
</div>
|
||||
<div className="w-full bg-[var(--bg-quaternary)] rounded-full h-2">
|
||||
<div
|
||||
className="bg-[var(--color-info)]/50 h-2 rounded-full transition-all duration-500"
|
||||
style={{ width: `${progress}%` }}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-[var(--text-secondary)]">
|
||||
{getTimeRemaining(batch, stage)}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isCompleted && (
|
||||
<div className="flex items-center text-[var(--color-success)] text-sm">
|
||||
<span className="mr-1">✓</span>
|
||||
Completado
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isActive && !isCompleted && (
|
||||
<p className="text-xs text-[var(--text-tertiary)]">
|
||||
~{stage.estimatedMinutes}min
|
||||
</p>
|
||||
)}
|
||||
|
||||
{stage.temperature && isActive && (
|
||||
<p className="text-xs text-[var(--text-secondary)] mt-1">
|
||||
🌡️ {stage.temperature.min}-{stage.temperature.max}{stage.temperature.unit}
|
||||
</p>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const renderBatchDetails = (batch: ProductionBatchResponse) => (
|
||||
<Card className="p-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div>
|
||||
<h4 className="font-semibold text-[var(--text-primary)]">{batch.recipe?.name || 'Producto'}</h4>
|
||||
<p className="text-sm text-[var(--text-secondary)]">Lote #{batch.batch_number}</p>
|
||||
<Badge className={PRIORITY_COLORS[batch.priority]} size="sm">
|
||||
{batch.priority === ProductionPriorityEnum.LOW && 'Baja'}
|
||||
{batch.priority === ProductionPriorityEnum.NORMAL && 'Normal'}
|
||||
{batch.priority === ProductionPriorityEnum.HIGH && 'Alta'}
|
||||
{batch.priority === ProductionPriorityEnum.URGENT && 'Urgente'}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="text-sm text-[var(--text-secondary)]">Cantidad planificada</p>
|
||||
<p className="font-semibold">{batch.planned_quantity} unidades</p>
|
||||
{batch.actual_quantity && (
|
||||
<>
|
||||
<p className="text-sm text-[var(--text-secondary)] mt-1">Cantidad real</p>
|
||||
<p className="font-semibold">{batch.actual_quantity} unidades</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="text-sm text-[var(--text-secondary)]">Inicio planificado</p>
|
||||
<p className="font-semibold">
|
||||
{new Date(batch.planned_start_date).toLocaleString('es-ES')}
|
||||
</p>
|
||||
{batch.actual_start_date && (
|
||||
<>
|
||||
<p className="text-sm text-[var(--text-secondary)] mt-1">Inicio real</p>
|
||||
<p className="font-semibold">
|
||||
{new Date(batch.actual_start_date).toLocaleString('es-ES')}
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{batch.notes && (
|
||||
<div className="mt-4 p-3 bg-[var(--bg-secondary)] rounded-lg">
|
||||
<p className="text-sm text-[var(--text-secondary)]">{batch.notes}</p>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
|
||||
const renderAlerts = () => (
|
||||
<Card className="p-4">
|
||||
<h3 className="font-semibold text-lg mb-4 flex items-center gap-2">
|
||||
🚨 Alertas activas
|
||||
</h3>
|
||||
|
||||
{alerts.length === 0 ? (
|
||||
<p className="text-[var(--text-tertiary)] text-center py-4">No hay alertas activas</p>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{alerts.map((alert) => (
|
||||
<div
|
||||
key={alert.id}
|
||||
className={`p-3 rounded-lg border-l-4 ${
|
||||
alert.severity === 'high'
|
||||
? 'bg-red-50 border-red-500'
|
||||
: alert.severity === 'medium'
|
||||
? 'bg-yellow-50 border-yellow-500'
|
||||
: 'bg-[var(--color-info)]/5 border-blue-500'
|
||||
}`}
|
||||
>
|
||||
<div className="flex justify-between items-start">
|
||||
<div>
|
||||
<p className="font-medium text-sm">
|
||||
Lote #{batches.find(b => b.id === alert.batchId)?.batch_number} - {PRODUCTION_STAGES[alert.stage]?.spanishName}
|
||||
</p>
|
||||
<p className="text-sm text-[var(--text-secondary)] mt-1">{alert.message}</p>
|
||||
</div>
|
||||
<Badge
|
||||
className={
|
||||
alert.severity === 'high'
|
||||
? 'bg-[var(--color-error)]/10 text-[var(--color-error)]'
|
||||
: alert.severity === 'medium'
|
||||
? 'bg-yellow-100 text-yellow-800'
|
||||
: 'bg-[var(--color-info)]/10 text-[var(--color-info)]'
|
||||
}
|
||||
>
|
||||
{alert.severity === 'high' && 'Alta'}
|
||||
{alert.severity === 'medium' && 'Media'}
|
||||
{alert.severity === 'low' && 'Baja'}
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="text-xs text-[var(--text-tertiary)] mt-2">
|
||||
{new Date(alert.timestamp).toLocaleTimeString('es-ES')}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={`space-y-6 ${className}`}>
|
||||
<div className="flex justify-between items-center">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold text-[var(--text-primary)]">Seguimiento de Lotes</h2>
|
||||
<p className="text-[var(--text-secondary)]">Rastrea el progreso de los lotes a través de las etapas de producción</p>
|
||||
</div>
|
||||
|
||||
{selectedBatch && (
|
||||
<div className="flex gap-2">
|
||||
<Select
|
||||
value={selectedBatch.id}
|
||||
onChange={(e) => {
|
||||
const batch = batches.find(b => b.id === e.target.value);
|
||||
if (batch) setSelectedBatch(batch);
|
||||
}}
|
||||
className="w-64"
|
||||
>
|
||||
{batches.map((batch) => (
|
||||
<option key={batch.id} value={batch.id}>
|
||||
Lote #{batch.batch_number} - {batch.recipe?.name}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={loadBatches}
|
||||
disabled={loading}
|
||||
>
|
||||
{loading ? 'Actualizando...' : 'Actualizar'}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="flex justify-center items-center h-64">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{selectedBatch ? (
|
||||
<div className="space-y-6">
|
||||
{renderBatchDetails(selectedBatch)}
|
||||
{renderStageProgress(selectedBatch)}
|
||||
{renderAlerts()}
|
||||
</div>
|
||||
) : (
|
||||
<Card className="p-8 text-center">
|
||||
<p className="text-[var(--text-tertiary)]">No hay lotes en producción actualmente</p>
|
||||
<Button variant="primary" className="mt-4">
|
||||
Ver todos los lotes
|
||||
</Button>
|
||||
</Card>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Stage Update Modal */}
|
||||
<Modal
|
||||
isOpen={isStageModalOpen}
|
||||
onClose={() => {
|
||||
setIsStageModalOpen(false);
|
||||
setSelectedStageForUpdate(null);
|
||||
}}
|
||||
title={`${selectedStageForUpdate?.spanishName} - Lote #${selectedBatch?.batch_number}`}
|
||||
>
|
||||
{selectedStageForUpdate && selectedBatch && (
|
||||
<div className="space-y-4">
|
||||
<div className="bg-[var(--bg-secondary)] p-4 rounded-lg">
|
||||
<h4 className="font-medium mb-2 flex items-center gap-2">
|
||||
<span className="text-xl">{selectedStageForUpdate.icon}</span>
|
||||
{selectedStageForUpdate.spanishName}
|
||||
</h4>
|
||||
<p className="text-sm text-[var(--text-secondary)] mb-2">
|
||||
Duración estimada: {selectedStageForUpdate.estimatedMinutes} minutos
|
||||
</p>
|
||||
|
||||
{selectedStageForUpdate.temperature && (
|
||||
<p className="text-sm text-[var(--text-secondary)]">
|
||||
🌡️ Temperatura: {selectedStageForUpdate.temperature.min}-{selectedStageForUpdate.temperature.max}{selectedStageForUpdate.temperature.unit}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{selectedStageForUpdate.criticalControlPoint && (
|
||||
<Badge className="bg-[var(--color-primary)]/10 text-[var(--color-primary)] mt-2">
|
||||
Punto Crítico de Control (PCC)
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{selectedStageForUpdate.requiresQualityCheck && (
|
||||
<div className="bg-yellow-50 border border-yellow-200 p-3 rounded-lg">
|
||||
<p className="text-sm text-yellow-800">
|
||||
⚠️ Esta etapa requiere control de calidad antes de continuar
|
||||
</p>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="mt-2"
|
||||
onClick={() => handleQualityCheck(selectedBatch, selectedStageForUpdate)}
|
||||
>
|
||||
Realizar control de calidad
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-[var(--text-secondary)] mb-2">
|
||||
Notas de la etapa
|
||||
</label>
|
||||
<Input
|
||||
as="textarea"
|
||||
rows={3}
|
||||
placeholder="Añadir observaciones o notas sobre esta etapa..."
|
||||
value={stageNotes[`${selectedBatch.id}-${selectedStageForUpdate.id}`] || ''}
|
||||
onChange={(e) => setStageNotes(prev => ({
|
||||
...prev,
|
||||
[`${selectedBatch.id}-${selectedStageForUpdate.id}`]: e.target.value,
|
||||
}))}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-2 pt-4">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setIsStageModalOpen(false)}
|
||||
>
|
||||
Cerrar
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={() => {
|
||||
const notes = stageNotes[`${selectedBatch.id}-${selectedStageForUpdate.id}`];
|
||||
updateStage(selectedBatch.id, selectedStageForUpdate.id, notes);
|
||||
setIsStageModalOpen(false);
|
||||
}}
|
||||
>
|
||||
Marcar como completado
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
|
||||
{/* Quick Stats */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||
<Card className="p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-[var(--text-secondary)]">Lotes activos</p>
|
||||
<p className="text-2xl font-bold text-[var(--color-info)]">{batches.length}</p>
|
||||
</div>
|
||||
<span className="text-2xl">📊</span>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className="p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-[var(--text-secondary)]">Alertas activas</p>
|
||||
<p className="text-2xl font-bold text-[var(--color-error)]">{alerts.length}</p>
|
||||
</div>
|
||||
<span className="text-2xl">🚨</span>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className="p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-[var(--text-secondary)]">En horneado</p>
|
||||
<p className="text-2xl font-bold text-[var(--color-primary)]">3</p>
|
||||
</div>
|
||||
<span className="text-2xl">🔥</span>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className="p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-[var(--text-secondary)]">Completados hoy</p>
|
||||
<p className="text-2xl font-bold text-[var(--color-success)]">12</p>
|
||||
</div>
|
||||
<span className="text-2xl">✅</span>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default BatchTracker;
|
||||
@@ -1,632 +0,0 @@
|
||||
import React, { useState, useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Card, CardHeader, CardBody } from '../../ui/Card';
|
||||
import { Button } from '../../ui/Button';
|
||||
import { Input } from '../../ui/Input';
|
||||
import { Badge } from '../../ui/Badge';
|
||||
import { Modal } from '../../ui/Modal';
|
||||
import { Tabs, TabsList, TabsTrigger, TabsContent } from '../../ui/Tabs';
|
||||
import { StatsGrid } from '../../ui/Stats';
|
||||
import {
|
||||
Settings,
|
||||
AlertTriangle,
|
||||
CheckCircle,
|
||||
Wrench,
|
||||
Calendar,
|
||||
Clock,
|
||||
Thermometer,
|
||||
Activity,
|
||||
Zap,
|
||||
TrendingUp,
|
||||
Search,
|
||||
Plus,
|
||||
Filter,
|
||||
Download,
|
||||
BarChart3,
|
||||
Bell,
|
||||
MapPin,
|
||||
User
|
||||
} from 'lucide-react';
|
||||
import { useCurrentTenant } from '../../../stores/tenant.store';
|
||||
|
||||
export interface Equipment {
|
||||
id: string;
|
||||
name: string;
|
||||
type: 'oven' | 'mixer' | 'proofer' | 'freezer' | 'packaging' | 'other';
|
||||
model: string;
|
||||
serialNumber: string;
|
||||
location: string;
|
||||
status: 'operational' | 'maintenance' | 'down' | 'warning';
|
||||
installDate: string;
|
||||
lastMaintenance: string;
|
||||
nextMaintenance: string;
|
||||
maintenanceInterval: number; // days
|
||||
temperature?: number;
|
||||
targetTemperature?: number;
|
||||
efficiency: number;
|
||||
uptime: number;
|
||||
energyUsage: number;
|
||||
utilizationToday: number;
|
||||
alerts: Array<{
|
||||
id: string;
|
||||
type: 'warning' | 'critical' | 'info';
|
||||
message: string;
|
||||
timestamp: string;
|
||||
acknowledged: boolean;
|
||||
}>;
|
||||
maintenanceHistory: Array<{
|
||||
id: string;
|
||||
date: string;
|
||||
type: 'preventive' | 'corrective' | 'emergency';
|
||||
description: string;
|
||||
technician: string;
|
||||
cost: number;
|
||||
downtime: number; // hours
|
||||
partsUsed: string[];
|
||||
}>;
|
||||
specifications: {
|
||||
power: number; // kW
|
||||
capacity: number;
|
||||
dimensions: {
|
||||
width: number;
|
||||
height: number;
|
||||
depth: number;
|
||||
};
|
||||
weight: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface EquipmentManagerProps {
|
||||
className?: string;
|
||||
equipment?: Equipment[];
|
||||
onCreateEquipment?: () => void;
|
||||
onEditEquipment?: (equipmentId: string) => void;
|
||||
onScheduleMaintenance?: (equipmentId: string) => void;
|
||||
onAcknowledgeAlert?: (equipmentId: string, alertId: string) => void;
|
||||
onViewMaintenanceHistory?: (equipmentId: string) => void;
|
||||
}
|
||||
|
||||
const MOCK_EQUIPMENT: Equipment[] = [
|
||||
{
|
||||
id: '1',
|
||||
name: 'Horno Principal #1',
|
||||
type: 'oven',
|
||||
model: 'Miwe Condo CO 4.1212',
|
||||
serialNumber: 'MCO-2021-001',
|
||||
location: '<27>rea de Horneado - Zona A',
|
||||
status: 'operational',
|
||||
installDate: '2021-03-15',
|
||||
lastMaintenance: '2024-01-15',
|
||||
nextMaintenance: '2024-04-15',
|
||||
maintenanceInterval: 90,
|
||||
temperature: 220,
|
||||
targetTemperature: 220,
|
||||
efficiency: 92,
|
||||
uptime: 98.5,
|
||||
energyUsage: 45.2,
|
||||
utilizationToday: 87,
|
||||
alerts: [],
|
||||
maintenanceHistory: [
|
||||
{
|
||||
id: '1',
|
||||
date: '2024-01-15',
|
||||
type: 'preventive',
|
||||
description: 'Limpieza general y calibraci<63>n de termostatos',
|
||||
technician: 'Juan P<>rez',
|
||||
cost: 150,
|
||||
downtime: 2,
|
||||
partsUsed: ['Filtros de aire', 'Sellos de puerta']
|
||||
}
|
||||
],
|
||||
specifications: {
|
||||
power: 45,
|
||||
capacity: 24,
|
||||
dimensions: { width: 200, height: 180, depth: 120 },
|
||||
weight: 850
|
||||
}
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
name: 'Batidora Industrial #2',
|
||||
type: 'mixer',
|
||||
model: 'Hobart HL800',
|
||||
serialNumber: 'HHL-2020-002',
|
||||
location: '<27>rea de Preparaci<63>n - Zona B',
|
||||
status: 'warning',
|
||||
installDate: '2020-08-10',
|
||||
lastMaintenance: '2024-01-20',
|
||||
nextMaintenance: '2024-02-20',
|
||||
maintenanceInterval: 30,
|
||||
efficiency: 88,
|
||||
uptime: 94.2,
|
||||
energyUsage: 12.8,
|
||||
utilizationToday: 76,
|
||||
alerts: [
|
||||
{
|
||||
id: '1',
|
||||
type: 'warning',
|
||||
message: 'Vibraci<63>n inusual detectada en el motor',
|
||||
timestamp: '2024-01-23T10:30:00Z',
|
||||
acknowledged: false
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
type: 'info',
|
||||
message: 'Mantenimiento programado en 5 d<>as',
|
||||
timestamp: '2024-01-23T08:00:00Z',
|
||||
acknowledged: true
|
||||
}
|
||||
],
|
||||
maintenanceHistory: [
|
||||
{
|
||||
id: '1',
|
||||
date: '2024-01-20',
|
||||
type: 'corrective',
|
||||
description: 'Reemplazo de correas de transmisi<73>n',
|
||||
technician: 'Mar<61>a Gonz<6E>lez',
|
||||
cost: 85,
|
||||
downtime: 4,
|
||||
partsUsed: ['Correa tipo V', 'Rodamientos']
|
||||
}
|
||||
],
|
||||
specifications: {
|
||||
power: 15,
|
||||
capacity: 80,
|
||||
dimensions: { width: 120, height: 150, depth: 80 },
|
||||
weight: 320
|
||||
}
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
name: 'C<>mara de Fermentaci<63>n #1',
|
||||
type: 'proofer',
|
||||
model: 'Bongard EUROPA 16.18',
|
||||
serialNumber: 'BEU-2022-001',
|
||||
location: '<27>rea de Fermentaci<63>n',
|
||||
status: 'maintenance',
|
||||
installDate: '2022-06-20',
|
||||
lastMaintenance: '2024-01-23',
|
||||
nextMaintenance: '2024-01-24',
|
||||
maintenanceInterval: 60,
|
||||
temperature: 32,
|
||||
targetTemperature: 35,
|
||||
efficiency: 0,
|
||||
uptime: 85.1,
|
||||
energyUsage: 0,
|
||||
utilizationToday: 0,
|
||||
alerts: [
|
||||
{
|
||||
id: '1',
|
||||
type: 'info',
|
||||
message: 'En mantenimiento programado',
|
||||
timestamp: '2024-01-23T06:00:00Z',
|
||||
acknowledged: true
|
||||
}
|
||||
],
|
||||
maintenanceHistory: [
|
||||
{
|
||||
id: '1',
|
||||
date: '2024-01-23',
|
||||
type: 'preventive',
|
||||
description: 'Mantenimiento programado - sistema de humidificaci<63>n',
|
||||
technician: 'Carlos Rodr<64>guez',
|
||||
cost: 200,
|
||||
downtime: 8,
|
||||
partsUsed: ['Sensor de humedad', 'V<>lvulas']
|
||||
}
|
||||
],
|
||||
specifications: {
|
||||
power: 8,
|
||||
capacity: 16,
|
||||
dimensions: { width: 180, height: 200, depth: 100 },
|
||||
weight: 450
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
const EquipmentManager: React.FC<EquipmentManagerProps> = ({
|
||||
className,
|
||||
equipment = MOCK_EQUIPMENT,
|
||||
onCreateEquipment,
|
||||
onEditEquipment,
|
||||
onScheduleMaintenance,
|
||||
onAcknowledgeAlert,
|
||||
onViewMaintenanceHistory
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const [activeTab, setActiveTab] = useState('overview');
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [statusFilter, setStatusFilter] = useState<Equipment['status'] | 'all'>('all');
|
||||
const [selectedEquipment, setSelectedEquipment] = useState<Equipment | null>(null);
|
||||
const [showEquipmentModal, setShowEquipmentModal] = useState(false);
|
||||
|
||||
const filteredEquipment = useMemo(() => {
|
||||
return equipment.filter(eq => {
|
||||
const matchesSearch = !searchQuery ||
|
||||
eq.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
eq.location.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
eq.type.toLowerCase().includes(searchQuery.toLowerCase());
|
||||
|
||||
const matchesStatus = statusFilter === 'all' || eq.status === statusFilter;
|
||||
|
||||
return matchesSearch && matchesStatus;
|
||||
});
|
||||
}, [equipment, searchQuery, statusFilter]);
|
||||
|
||||
const equipmentStats = useMemo(() => {
|
||||
const total = equipment.length;
|
||||
const operational = equipment.filter(e => e.status === 'operational').length;
|
||||
const warning = equipment.filter(e => e.status === 'warning').length;
|
||||
const maintenance = equipment.filter(e => e.status === 'maintenance').length;
|
||||
const down = equipment.filter(e => e.status === 'down').length;
|
||||
const avgEfficiency = equipment.reduce((sum, e) => sum + e.efficiency, 0) / total;
|
||||
const avgUptime = equipment.reduce((sum, e) => sum + e.uptime, 0) / total;
|
||||
const totalAlerts = equipment.reduce((sum, e) => sum + e.alerts.filter(a => !a.acknowledged).length, 0);
|
||||
|
||||
return {
|
||||
total,
|
||||
operational,
|
||||
warning,
|
||||
maintenance,
|
||||
down,
|
||||
avgEfficiency,
|
||||
avgUptime,
|
||||
totalAlerts
|
||||
};
|
||||
}, [equipment]);
|
||||
|
||||
const getStatusConfig = (status: Equipment['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 getTypeIcon = (type: Equipment['type']) => {
|
||||
const icons = {
|
||||
oven: Thermometer,
|
||||
mixer: Activity,
|
||||
proofer: Settings,
|
||||
freezer: Zap,
|
||||
packaging: Settings,
|
||||
other: Settings
|
||||
};
|
||||
return icons[type];
|
||||
};
|
||||
|
||||
const formatDateTime = (dateString: string) => {
|
||||
return new Date(dateString).toLocaleDateString('es-ES', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric'
|
||||
});
|
||||
};
|
||||
|
||||
const stats = [
|
||||
{
|
||||
title: t('equipment.stats.total', 'Total Equipment'),
|
||||
value: equipmentStats.total,
|
||||
icon: Settings,
|
||||
variant: 'default' as const
|
||||
},
|
||||
{
|
||||
title: t('equipment.stats.operational', 'Operational'),
|
||||
value: equipmentStats.operational,
|
||||
icon: CheckCircle,
|
||||
variant: 'success' as const,
|
||||
subtitle: `${((equipmentStats.operational / equipmentStats.total) * 100).toFixed(1)}%`
|
||||
},
|
||||
{
|
||||
title: t('equipment.stats.avg_efficiency', 'Avg Efficiency'),
|
||||
value: `${equipmentStats.avgEfficiency.toFixed(1)}%`,
|
||||
icon: TrendingUp,
|
||||
variant: equipmentStats.avgEfficiency >= 90 ? 'success' as const : 'warning' as const
|
||||
},
|
||||
{
|
||||
title: t('equipment.stats.alerts', 'Active Alerts'),
|
||||
value: equipmentStats.totalAlerts,
|
||||
icon: Bell,
|
||||
variant: equipmentStats.totalAlerts === 0 ? 'success' as const : 'error' as const
|
||||
}
|
||||
];
|
||||
|
||||
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.manager.title', 'Equipment Management')}
|
||||
</h3>
|
||||
<p className="text-sm text-[var(--text-secondary)]">
|
||||
{t('equipment.manager.subtitle', 'Monitor and manage production equipment')}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex space-x-2">
|
||||
<Button variant="outline" size="sm">
|
||||
<Download className="w-4 h-4 mr-2" />
|
||||
{t('equipment.actions.export', 'Export')}
|
||||
</Button>
|
||||
<Button variant="primary" size="sm" onClick={onCreateEquipment}>
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
{t('equipment.actions.add', 'Add Equipment')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardBody className="space-y-6">
|
||||
{/* Stats */}
|
||||
<StatsGrid stats={stats} columns={4} gap="md" />
|
||||
|
||||
{/* Filters */}
|
||||
<div className="flex flex-col sm:flex-row gap-4">
|
||||
<div className="flex-1">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 w-4 h-4 text-[var(--text-tertiary)]" />
|
||||
<Input
|
||||
placeholder={t('equipment.search.placeholder', 'Search equipment...')}
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="pl-10"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Filter className="w-4 h-4 text-[var(--text-tertiary)]" />
|
||||
<select
|
||||
value={statusFilter}
|
||||
onChange={(e) => setStatusFilter(e.target.value as Equipment['status'] | 'all')}
|
||||
className="px-3 py-2 border border-[var(--border-primary)] rounded-md bg-[var(--bg-primary)] text-[var(--text-primary)]"
|
||||
>
|
||||
<option value="all">{t('equipment.filter.all', 'All Status')}</option>
|
||||
<option value="operational">{t('equipment.status.operational', 'Operational')}</option>
|
||||
<option value="warning">{t('equipment.status.warning', 'Warning')}</option>
|
||||
<option value="maintenance">{t('equipment.status.maintenance', 'Maintenance')}</option>
|
||||
<option value="down">{t('equipment.status.down', 'Down')}</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Equipment List */}
|
||||
<Tabs value={activeTab} onValueChange={setActiveTab}>
|
||||
<TabsList className="grid w-full grid-cols-3">
|
||||
<TabsTrigger value="overview">
|
||||
{t('equipment.tabs.overview', 'Overview')}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="maintenance">
|
||||
{t('equipment.tabs.maintenance', 'Maintenance')}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="alerts">
|
||||
{t('equipment.tabs.alerts', 'Alerts')}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="overview" className="space-y-4">
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
{filteredEquipment.map((eq) => {
|
||||
const statusConfig = getStatusConfig(eq.status);
|
||||
const TypeIcon = getTypeIcon(eq.type);
|
||||
const StatusIcon = statusConfig.icon;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={eq.id}
|
||||
className="p-4 bg-[var(--bg-secondary)] rounded-lg hover:bg-[var(--bg-tertiary)] transition-colors cursor-pointer"
|
||||
onClick={() => {
|
||||
setSelectedEquipment(eq);
|
||||
setShowEquipmentModal(true);
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="flex items-center space-x-2">
|
||||
<TypeIcon className="w-5 h-5 text-[var(--color-primary)]" />
|
||||
<h4 className="font-semibold text-[var(--text-primary)]">{eq.name}</h4>
|
||||
</div>
|
||||
<Badge variant={statusConfig.color}>
|
||||
{statusConfig.label}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2 text-sm">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-[var(--text-secondary)]">{t('equipment.efficiency', 'Efficiency')}:</span>
|
||||
<span className="font-medium">{eq.efficiency}%</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-[var(--text-secondary)]">{t('equipment.uptime', 'Uptime')}:</span>
|
||||
<span className="font-medium">{eq.uptime.toFixed(1)}%</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-[var(--text-secondary)]">{t('equipment.location', 'Location')}:</span>
|
||||
<span className="font-medium text-xs">{eq.location}</span>
|
||||
</div>
|
||||
{eq.temperature && (
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-[var(--text-secondary)]">{t('equipment.temperature', 'Temperature')}:</span>
|
||||
<span className="font-medium">{eq.temperature}<EFBFBD>C</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{eq.alerts.filter(a => !a.acknowledged).length > 0 && (
|
||||
<div className="mt-3 p-2 bg-orange-50 dark:bg-orange-900/20 rounded border-l-2 border-orange-500">
|
||||
<div className="flex items-center space-x-2">
|
||||
<AlertTriangle className="w-4 h-4 text-orange-500" />
|
||||
<span className="text-sm font-medium text-orange-700 dark:text-orange-300">
|
||||
{eq.alerts.filter(a => !a.acknowledged).length} {t('equipment.unread_alerts', 'unread alerts')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex justify-between mt-4 pt-3 border-t border-[var(--border-primary)]">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="xs"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onEditEquipment?.(eq.id);
|
||||
}}
|
||||
>
|
||||
{t('common.edit', 'Edit')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
size="xs"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onScheduleMaintenance?.(eq.id);
|
||||
}}
|
||||
>
|
||||
{t('equipment.actions.maintenance', 'Maintenance')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="maintenance" className="space-y-4">
|
||||
{equipment.map((eq) => (
|
||||
<div key={eq.id} className="p-4 bg-[var(--bg-secondary)] rounded-lg">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h4 className="font-semibold text-[var(--text-primary)]">{eq.name}</h4>
|
||||
<Badge variant={new Date(eq.nextMaintenance) <= new Date() ? 'error' : 'success'}>
|
||||
{new Date(eq.nextMaintenance) <= new Date() ? t('equipment.maintenance.overdue', 'Overdue') : t('equipment.maintenance.scheduled', 'Scheduled')}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 text-sm">
|
||||
<div>
|
||||
<span className="text-[var(--text-secondary)]">{t('equipment.maintenance.last', 'Last')}:</span>
|
||||
<div className="font-medium">{formatDateTime(eq.lastMaintenance)}</div>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-[var(--text-secondary)]">{t('equipment.maintenance.next', 'Next')}:</span>
|
||||
<div className="font-medium">{formatDateTime(eq.nextMaintenance)}</div>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-[var(--text-secondary)]">{t('equipment.maintenance.interval', 'Interval')}:</span>
|
||||
<div className="font-medium">{eq.maintenanceInterval} {t('common.days', 'days')}</div>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-[var(--text-secondary)]">{t('equipment.maintenance.history', 'History')}:</span>
|
||||
<div className="font-medium">{eq.maintenanceHistory.length} {t('equipment.maintenance.records', 'records')}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="alerts" className="space-y-4">
|
||||
{equipment.flatMap(eq =>
|
||||
eq.alerts.map(alert => (
|
||||
<div key={`${eq.id}-${alert.id}`} className={`p-4 rounded-lg border-l-4 ${
|
||||
alert.type === 'critical' ? 'bg-red-50 border-red-500 dark:bg-red-900/20' :
|
||||
alert.type === 'warning' ? 'bg-orange-50 border-orange-500 dark:bg-orange-900/20' :
|
||||
'bg-blue-50 border-blue-500 dark:bg-blue-900/20'
|
||||
}`}>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="flex items-center space-x-2">
|
||||
<AlertTriangle className={`w-5 h-5 ${
|
||||
alert.type === 'critical' ? 'text-red-500' :
|
||||
alert.type === 'warning' ? 'text-orange-500' : 'text-blue-500'
|
||||
}`} />
|
||||
<h4 className="font-semibold text-[var(--text-primary)]">{eq.name}</h4>
|
||||
<Badge variant={alert.acknowledged ? 'success' : 'warning'}>
|
||||
{alert.acknowledged ? t('equipment.alerts.acknowledged', 'Acknowledged') : t('equipment.alerts.new', 'New')}
|
||||
</Badge>
|
||||
</div>
|
||||
<span className="text-sm text-[var(--text-secondary)]">
|
||||
{new Date(alert.timestamp).toLocaleString('es-ES')}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-[var(--text-secondary)] mb-3">{alert.message}</p>
|
||||
{!alert.acknowledged && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => onAcknowledgeAlert?.(eq.id, alert.id)}
|
||||
>
|
||||
{t('equipment.alerts.acknowledge', 'Acknowledge')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
{/* Equipment Details Modal */}
|
||||
{selectedEquipment && (
|
||||
<Modal
|
||||
isOpen={showEquipmentModal}
|
||||
onClose={() => {
|
||||
setShowEquipmentModal(false);
|
||||
setSelectedEquipment(null);
|
||||
}}
|
||||
title={selectedEquipment.name}
|
||||
size="lg"
|
||||
>
|
||||
<div className="p-6 space-y-6">
|
||||
{/* Basic Info */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="text-sm font-medium text-[var(--text-secondary)]">{t('equipment.model', 'Model')}</label>
|
||||
<p className="text-[var(--text-primary)]">{selectedEquipment.model}</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm font-medium text-[var(--text-secondary)]">{t('equipment.serial', 'Serial Number')}</label>
|
||||
<p className="text-[var(--text-primary)]">{selectedEquipment.serialNumber}</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm font-medium text-[var(--text-secondary)]">{t('equipment.location', 'Location')}</label>
|
||||
<p className="text-[var(--text-primary)]">{selectedEquipment.location}</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm font-medium text-[var(--text-secondary)]">{t('equipment.install_date', 'Install Date')}</label>
|
||||
<p className="text-[var(--text-primary)]">{formatDateTime(selectedEquipment.installDate)}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Current Status */}
|
||||
<div className="grid grid-cols-3 gap-4 p-4 bg-[var(--bg-secondary)] rounded-lg">
|
||||
<div className="text-center">
|
||||
<div className="text-2xl font-bold text-[var(--text-primary)]">{selectedEquipment.efficiency}%</div>
|
||||
<div className="text-sm text-[var(--text-secondary)]">{t('equipment.efficiency', 'Efficiency')}</div>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className="text-2xl font-bold text-[var(--text-primary)]">{selectedEquipment.uptime.toFixed(1)}%</div>
|
||||
<div className="text-sm text-[var(--text-secondary)]">{t('equipment.uptime', 'Uptime')}</div>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className="text-2xl font-bold text-[var(--text-primary)]">{selectedEquipment.energyUsage} kW</div>
|
||||
<div className="text-sm text-[var(--text-secondary)]">{t('equipment.energy_usage', 'Energy Usage')}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex justify-end space-x-3">
|
||||
<Button variant="outline" onClick={() => onViewMaintenanceHistory?.(selectedEquipment.id)}>
|
||||
{t('equipment.actions.view_history', 'View History')}
|
||||
</Button>
|
||||
<Button variant="secondary" onClick={() => onEditEquipment?.(selectedEquipment.id)}>
|
||||
{t('common.edit', 'Edit')}
|
||||
</Button>
|
||||
<Button variant="primary" onClick={() => onScheduleMaintenance?.(selectedEquipment.id)}>
|
||||
{t('equipment.actions.schedule_maintenance', 'Schedule Maintenance')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
)}
|
||||
</CardBody>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default EquipmentManager;
|
||||
@@ -1,377 +0,0 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
CheckCircle,
|
||||
Clock,
|
||||
AlertTriangle,
|
||||
Play,
|
||||
Pause,
|
||||
ArrowRight,
|
||||
Settings,
|
||||
Thermometer,
|
||||
Scale,
|
||||
Eye
|
||||
} from 'lucide-react';
|
||||
import { Button, Badge, Card, ProgressBar } from '../../ui';
|
||||
import { ProcessStage } from '../../../api/types/qualityTemplates';
|
||||
import type { ProductionBatchResponse } from '../../../api/types/production';
|
||||
|
||||
interface ProcessStageTrackerProps {
|
||||
batch: ProductionBatchResponse;
|
||||
onStageAdvance?: (stage: ProcessStage) => void;
|
||||
onQualityCheck?: (stage: ProcessStage) => void;
|
||||
onStageStart?: (stage: ProcessStage) => void;
|
||||
onStagePause?: (stage: ProcessStage) => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const PROCESS_STAGES_ORDER: ProcessStage[] = [
|
||||
ProcessStage.MIXING,
|
||||
ProcessStage.PROOFING,
|
||||
ProcessStage.SHAPING,
|
||||
ProcessStage.BAKING,
|
||||
ProcessStage.COOLING,
|
||||
ProcessStage.PACKAGING,
|
||||
ProcessStage.FINISHING
|
||||
];
|
||||
|
||||
const STAGE_CONFIG = {
|
||||
[ProcessStage.MIXING]: {
|
||||
label: 'Mezclado',
|
||||
icon: Settings,
|
||||
color: 'bg-blue-500',
|
||||
description: 'Preparación y mezclado de ingredientes'
|
||||
},
|
||||
[ProcessStage.PROOFING]: {
|
||||
label: 'Fermentación',
|
||||
icon: Clock,
|
||||
color: 'bg-yellow-500',
|
||||
description: 'Proceso de fermentación y crecimiento'
|
||||
},
|
||||
[ProcessStage.SHAPING]: {
|
||||
label: 'Formado',
|
||||
icon: Settings,
|
||||
color: 'bg-purple-500',
|
||||
description: 'Dar forma al producto'
|
||||
},
|
||||
[ProcessStage.BAKING]: {
|
||||
label: 'Horneado',
|
||||
icon: Thermometer,
|
||||
color: 'bg-red-500',
|
||||
description: 'Cocción en horno'
|
||||
},
|
||||
[ProcessStage.COOLING]: {
|
||||
label: 'Enfriado',
|
||||
icon: Settings,
|
||||
color: 'bg-cyan-500',
|
||||
description: 'Enfriamiento del producto'
|
||||
},
|
||||
[ProcessStage.PACKAGING]: {
|
||||
label: 'Empaquetado',
|
||||
icon: Settings,
|
||||
color: 'bg-green-500',
|
||||
description: 'Empaque del producto terminado'
|
||||
},
|
||||
[ProcessStage.FINISHING]: {
|
||||
label: 'Acabado',
|
||||
icon: CheckCircle,
|
||||
color: 'bg-indigo-500',
|
||||
description: 'Toques finales y control final'
|
||||
}
|
||||
};
|
||||
|
||||
export const ProcessStageTracker: React.FC<ProcessStageTrackerProps> = ({
|
||||
batch,
|
||||
onStageAdvance,
|
||||
onQualityCheck,
|
||||
onStageStart,
|
||||
onStagePause,
|
||||
className = ''
|
||||
}) => {
|
||||
const currentStage = batch.current_process_stage;
|
||||
const stageHistory = batch.process_stage_history || {};
|
||||
const pendingQualityChecks = batch.pending_quality_checks || {};
|
||||
const completedQualityChecks = batch.completed_quality_checks || {};
|
||||
|
||||
const getCurrentStageIndex = () => {
|
||||
if (!currentStage) return -1;
|
||||
return PROCESS_STAGES_ORDER.indexOf(currentStage);
|
||||
};
|
||||
|
||||
const isStageCompleted = (stage: ProcessStage): boolean => {
|
||||
const stageIndex = PROCESS_STAGES_ORDER.indexOf(stage);
|
||||
const currentIndex = getCurrentStageIndex();
|
||||
return stageIndex < currentIndex || (stageIndex === currentIndex && batch.status === 'COMPLETED');
|
||||
};
|
||||
|
||||
const isStageActive = (stage: ProcessStage): boolean => {
|
||||
return currentStage === stage && batch.status === 'IN_PROGRESS';
|
||||
};
|
||||
|
||||
const isStageUpcoming = (stage: ProcessStage): boolean => {
|
||||
const stageIndex = PROCESS_STAGES_ORDER.indexOf(stage);
|
||||
const currentIndex = getCurrentStageIndex();
|
||||
return stageIndex > currentIndex;
|
||||
};
|
||||
|
||||
const hasQualityChecksRequired = (stage: ProcessStage): boolean => {
|
||||
return pendingQualityChecks[stage]?.length > 0;
|
||||
};
|
||||
|
||||
const hasQualityChecksCompleted = (stage: ProcessStage): boolean => {
|
||||
return completedQualityChecks[stage]?.length > 0;
|
||||
};
|
||||
|
||||
const getStageProgress = (stage: ProcessStage): number => {
|
||||
if (isStageCompleted(stage)) return 100;
|
||||
if (isStageActive(stage)) {
|
||||
// Calculate progress based on time elapsed vs estimated duration
|
||||
const stageStartTime = stageHistory[stage]?.start_time;
|
||||
if (stageStartTime) {
|
||||
const elapsed = Date.now() - new Date(stageStartTime).getTime();
|
||||
const estimated = 30 * 60 * 1000; // Default 30 minutes per stage
|
||||
return Math.min(100, (elapsed / estimated) * 100);
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
};
|
||||
|
||||
const getStageStatus = (stage: ProcessStage) => {
|
||||
if (isStageCompleted(stage)) {
|
||||
return hasQualityChecksCompleted(stage)
|
||||
? { status: 'completed', icon: CheckCircle, color: 'text-green-500' }
|
||||
: { status: 'completed-no-quality', icon: CheckCircle, color: 'text-yellow-500' };
|
||||
}
|
||||
|
||||
if (isStageActive(stage)) {
|
||||
return hasQualityChecksRequired(stage)
|
||||
? { status: 'active-quality-pending', icon: AlertTriangle, color: 'text-orange-500' }
|
||||
: { status: 'active', icon: Play, color: 'text-blue-500' };
|
||||
}
|
||||
|
||||
if (isStageUpcoming(stage)) {
|
||||
return { status: 'upcoming', icon: Clock, color: 'text-gray-400' };
|
||||
}
|
||||
|
||||
return { status: 'pending', icon: Clock, color: 'text-gray-400' };
|
||||
};
|
||||
|
||||
const getOverallProgress = (): number => {
|
||||
const currentIndex = getCurrentStageIndex();
|
||||
if (currentIndex === -1) return 0;
|
||||
|
||||
const stageProgress = getStageProgress(currentStage!);
|
||||
return ((currentIndex + stageProgress / 100) / PROCESS_STAGES_ORDER.length) * 100;
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className={`p-6 ${className}`}>
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-[var(--text-primary)]">
|
||||
Etapas del Proceso
|
||||
</h3>
|
||||
<p className="text-sm text-[var(--text-secondary)] mt-1">
|
||||
{batch.product_name} • Lote #{batch.batch_number}
|
||||
</p>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<div className="text-2xl font-bold text-[var(--text-primary)]">
|
||||
{Math.round(getOverallProgress())}%
|
||||
</div>
|
||||
<div className="text-sm text-[var(--text-secondary)]">
|
||||
Completado
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Overall Progress Bar */}
|
||||
<ProgressBar
|
||||
percentage={getOverallProgress()}
|
||||
color="#f59e0b"
|
||||
height={8}
|
||||
showLabel={false}
|
||||
/>
|
||||
|
||||
{/* Stage Timeline */}
|
||||
<div className="space-y-4">
|
||||
{PROCESS_STAGES_ORDER.map((stage, index) => {
|
||||
const config = STAGE_CONFIG[stage];
|
||||
const status = getStageStatus(stage);
|
||||
const progress = getStageProgress(stage);
|
||||
const StageIcon = config.icon;
|
||||
const StatusIcon = status.icon;
|
||||
|
||||
return (
|
||||
<div key={stage} className="relative">
|
||||
{/* Connection Line */}
|
||||
{index < PROCESS_STAGES_ORDER.length - 1 && (
|
||||
<div
|
||||
className={`absolute left-6 top-12 w-0.5 h-8 ${
|
||||
isStageCompleted(stage) ? 'bg-green-500' : 'bg-gray-200'
|
||||
}`}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="flex items-start gap-4">
|
||||
{/* Stage Icon */}
|
||||
<div
|
||||
className={`relative flex items-center justify-center w-12 h-12 rounded-full border-2 ${
|
||||
isStageCompleted(stage)
|
||||
? 'bg-green-500 border-green-500 text-white'
|
||||
: isStageActive(stage)
|
||||
? 'bg-blue-500 border-blue-500 text-white'
|
||||
: 'bg-white border-gray-300 text-gray-400'
|
||||
}`}
|
||||
>
|
||||
<StageIcon className="w-5 h-5" />
|
||||
|
||||
{/* Status Indicator */}
|
||||
<div className="absolute -bottom-1 -right-1">
|
||||
<StatusIcon className={`w-4 h-4 ${status.color}`} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stage Content */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h4 className="font-medium text-[var(--text-primary)]">
|
||||
{config.label}
|
||||
</h4>
|
||||
<p className="text-sm text-[var(--text-secondary)]">
|
||||
{config.description}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Stage Actions */}
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Quality Check Indicators */}
|
||||
{hasQualityChecksRequired(stage) && (
|
||||
<Badge variant="warning" size="sm">
|
||||
Control pendiente
|
||||
</Badge>
|
||||
)}
|
||||
|
||||
{hasQualityChecksCompleted(stage) && (
|
||||
<Badge variant="success" size="sm">
|
||||
✓ Calidad
|
||||
</Badge>
|
||||
)}
|
||||
|
||||
{/* Action Buttons */}
|
||||
{isStageActive(stage) && (
|
||||
<div className="flex gap-1">
|
||||
{hasQualityChecksRequired(stage) && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => onQualityCheck?.(stage)}
|
||||
>
|
||||
<Eye className="w-4 h-4 mr-1" />
|
||||
Control
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<Button
|
||||
size="sm"
|
||||
variant="primary"
|
||||
onClick={() => onStageAdvance?.(stage)}
|
||||
disabled={hasQualityChecksRequired(stage)}
|
||||
>
|
||||
<ArrowRight className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{getCurrentStageIndex() === index - 1 && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => onStageStart?.(stage)}
|
||||
>
|
||||
<Play className="w-4 h-4 mr-1" />
|
||||
Iniciar
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stage Progress */}
|
||||
{isStageActive(stage) && progress > 0 && (
|
||||
<div className="mt-2">
|
||||
<ProgressBar
|
||||
percentage={progress}
|
||||
color="#3b82f6"
|
||||
height={4}
|
||||
showLabel={false}
|
||||
/>
|
||||
<p className="text-xs text-[var(--text-tertiary)] mt-1">
|
||||
Progreso de la etapa: {Math.round(progress)}%
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Stage Details */}
|
||||
{(isStageActive(stage) || isStageCompleted(stage)) && (
|
||||
<div className="mt-3 text-xs text-[var(--text-secondary)] space-y-1">
|
||||
{stageHistory[stage]?.start_time && (
|
||||
<div>
|
||||
Inicio: {new Date(stageHistory[stage].start_time).toLocaleTimeString('es-ES')}
|
||||
</div>
|
||||
)}
|
||||
{stageHistory[stage]?.end_time && (
|
||||
<div>
|
||||
Fin: {new Date(stageHistory[stage].end_time).toLocaleTimeString('es-ES')}
|
||||
</div>
|
||||
)}
|
||||
{completedQualityChecks[stage]?.length > 0 && (
|
||||
<div>
|
||||
Controles completados: {completedQualityChecks[stage].length}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Stage Summary */}
|
||||
<div className="bg-[var(--bg-secondary)] rounded-lg p-4">
|
||||
<div className="grid grid-cols-3 gap-4 text-center">
|
||||
<div>
|
||||
<div className="text-lg font-semibold text-green-500">
|
||||
{PROCESS_STAGES_ORDER.filter(s => isStageCompleted(s)).length}
|
||||
</div>
|
||||
<div className="text-xs text-[var(--text-secondary)]">
|
||||
Completadas
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-lg font-semibold text-blue-500">
|
||||
{currentStage ? 1 : 0}
|
||||
</div>
|
||||
<div className="text-xs text-[var(--text-secondary)]">
|
||||
En Proceso
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-lg font-semibold text-gray-400">
|
||||
{PROCESS_STAGES_ORDER.filter(s => isStageUpcoming(s)).length}
|
||||
</div>
|
||||
<div className="text-xs text-[var(--text-secondary)]">
|
||||
Pendientes
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProcessStageTracker;
|
||||
@@ -1,882 +0,0 @@
|
||||
import React, { useState, useCallback, useRef } from 'react';
|
||||
import { Card, Button, Badge, Input, Modal, Table, Select, DatePicker } from '../../ui';
|
||||
import { productionService, type QualityCheckResponse, QualityCheckStatus } from '../../../api';
|
||||
import type { QualityCheck, QualityCheckCriteria, ProductionBatch, QualityCheckType } from '../../../types/production.types';
|
||||
|
||||
interface QualityControlProps {
|
||||
className?: string;
|
||||
batchId?: string;
|
||||
checkType?: QualityCheckType;
|
||||
onQualityCheckCompleted?: (result: QualityCheck) => void;
|
||||
onCorrectiveActionRequired?: (check: QualityCheck, actions: string[]) => void;
|
||||
}
|
||||
|
||||
interface QualityCheckTemplate {
|
||||
id: string;
|
||||
name: string;
|
||||
spanishName: string;
|
||||
productTypes: string[];
|
||||
criteria: QualityChecklistItem[];
|
||||
requiresPhotos: boolean;
|
||||
passThreshold: number;
|
||||
criticalPoints: string[];
|
||||
}
|
||||
|
||||
interface QualityChecklistItem {
|
||||
id: string;
|
||||
category: string;
|
||||
description: string;
|
||||
spanishDescription: string;
|
||||
type: 'boolean' | 'numeric' | 'scale' | 'text';
|
||||
required: boolean;
|
||||
minValue?: number;
|
||||
maxValue?: number;
|
||||
unit?: string;
|
||||
acceptableCriteria?: string;
|
||||
weight: number;
|
||||
isCritical: boolean;
|
||||
}
|
||||
|
||||
interface QualityInspectionResult {
|
||||
checklistId: string;
|
||||
value: string | number | boolean;
|
||||
notes?: string;
|
||||
photo?: File;
|
||||
timestamp: string;
|
||||
inspector: string;
|
||||
}
|
||||
|
||||
const QUALITY_CHECK_TEMPLATES: Record<string, QualityCheckTemplate> = {
|
||||
visual_inspection: {
|
||||
id: 'visual_inspection',
|
||||
name: 'Visual Inspection',
|
||||
spanishName: 'Inspección Visual',
|
||||
productTypes: ['pan', 'bolleria', 'reposteria'],
|
||||
requiresPhotos: true,
|
||||
passThreshold: 80,
|
||||
criticalPoints: ['color_defects', 'structural_defects'],
|
||||
criteria: [
|
||||
{
|
||||
id: 'color_uniformity',
|
||||
category: 'appearance',
|
||||
description: 'Color uniformity',
|
||||
spanishDescription: 'Uniformidad del color',
|
||||
type: 'scale',
|
||||
required: true,
|
||||
minValue: 1,
|
||||
maxValue: 5,
|
||||
acceptableCriteria: 'Score 3 or higher',
|
||||
weight: 20,
|
||||
isCritical: false,
|
||||
},
|
||||
{
|
||||
id: 'surface_texture',
|
||||
category: 'appearance',
|
||||
description: 'Surface texture quality',
|
||||
spanishDescription: 'Calidad de la textura superficial',
|
||||
type: 'scale',
|
||||
required: true,
|
||||
minValue: 1,
|
||||
maxValue: 5,
|
||||
acceptableCriteria: 'Score 3 or higher',
|
||||
weight: 15,
|
||||
isCritical: false,
|
||||
},
|
||||
{
|
||||
id: 'shape_integrity',
|
||||
category: 'structure',
|
||||
description: 'Shape and form integrity',
|
||||
spanishDescription: 'Integridad de forma y estructura',
|
||||
type: 'boolean',
|
||||
required: true,
|
||||
acceptableCriteria: 'Must be true',
|
||||
weight: 25,
|
||||
isCritical: true,
|
||||
},
|
||||
{
|
||||
id: 'size_consistency',
|
||||
category: 'dimensions',
|
||||
description: 'Size consistency within batch',
|
||||
spanishDescription: 'Consistencia de tamaño en el lote',
|
||||
type: 'scale',
|
||||
required: true,
|
||||
minValue: 1,
|
||||
maxValue: 5,
|
||||
acceptableCriteria: 'Score 3 or higher',
|
||||
weight: 20,
|
||||
isCritical: false,
|
||||
},
|
||||
{
|
||||
id: 'defects_presence',
|
||||
category: 'defects',
|
||||
description: 'Visible defects or imperfections',
|
||||
spanishDescription: 'Defectos visibles o imperfecciones',
|
||||
type: 'boolean',
|
||||
required: true,
|
||||
acceptableCriteria: 'Must be false',
|
||||
weight: 20,
|
||||
isCritical: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
weight_check: {
|
||||
id: 'weight_check',
|
||||
name: 'Weight Check',
|
||||
spanishName: 'Control de Peso',
|
||||
productTypes: ['pan', 'bolleria', 'reposteria'],
|
||||
requiresPhotos: false,
|
||||
passThreshold: 95,
|
||||
criticalPoints: ['weight_variance'],
|
||||
criteria: [
|
||||
{
|
||||
id: 'individual_weight',
|
||||
category: 'weight',
|
||||
description: 'Individual piece weight',
|
||||
spanishDescription: 'Peso individual de la pieza',
|
||||
type: 'numeric',
|
||||
required: true,
|
||||
minValue: 0,
|
||||
unit: 'g',
|
||||
acceptableCriteria: 'Within ±5% of target',
|
||||
weight: 40,
|
||||
isCritical: true,
|
||||
},
|
||||
{
|
||||
id: 'batch_average_weight',
|
||||
category: 'weight',
|
||||
description: 'Batch average weight',
|
||||
spanishDescription: 'Peso promedio del lote',
|
||||
type: 'numeric',
|
||||
required: true,
|
||||
minValue: 0,
|
||||
unit: 'g',
|
||||
acceptableCriteria: 'Within ±3% of target',
|
||||
weight: 30,
|
||||
isCritical: true,
|
||||
},
|
||||
{
|
||||
id: 'weight_variance',
|
||||
category: 'consistency',
|
||||
description: 'Weight variance within batch',
|
||||
spanishDescription: 'Variación de peso dentro del lote',
|
||||
type: 'numeric',
|
||||
required: true,
|
||||
minValue: 0,
|
||||
unit: '%',
|
||||
acceptableCriteria: 'Less than 5%',
|
||||
weight: 30,
|
||||
isCritical: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
temperature_check: {
|
||||
id: 'temperature_check',
|
||||
name: 'Temperature Check',
|
||||
spanishName: 'Control de Temperatura',
|
||||
productTypes: ['pan', 'bolleria'],
|
||||
requiresPhotos: false,
|
||||
passThreshold: 90,
|
||||
criticalPoints: ['core_temperature'],
|
||||
criteria: [
|
||||
{
|
||||
id: 'core_temperature',
|
||||
category: 'temperature',
|
||||
description: 'Core temperature',
|
||||
spanishDescription: 'Temperatura del núcleo',
|
||||
type: 'numeric',
|
||||
required: true,
|
||||
minValue: 85,
|
||||
maxValue: 98,
|
||||
unit: '°C',
|
||||
acceptableCriteria: '88-95°C',
|
||||
weight: 60,
|
||||
isCritical: true,
|
||||
},
|
||||
{
|
||||
id: 'cooling_temperature',
|
||||
category: 'temperature',
|
||||
description: 'Cooling temperature',
|
||||
spanishDescription: 'Temperatura de enfriado',
|
||||
type: 'numeric',
|
||||
required: false,
|
||||
minValue: 18,
|
||||
maxValue: 25,
|
||||
unit: '°C',
|
||||
acceptableCriteria: '20-23°C',
|
||||
weight: 40,
|
||||
isCritical: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
packaging_quality: {
|
||||
id: 'packaging_quality',
|
||||
name: 'Packaging Quality',
|
||||
spanishName: 'Calidad del Empaquetado',
|
||||
productTypes: ['pan', 'bolleria', 'reposteria'],
|
||||
requiresPhotos: true,
|
||||
passThreshold: 85,
|
||||
criticalPoints: ['seal_integrity', 'labeling_accuracy'],
|
||||
criteria: [
|
||||
{
|
||||
id: 'seal_integrity',
|
||||
category: 'packaging',
|
||||
description: 'Package seal integrity',
|
||||
spanishDescription: 'Integridad del sellado del envase',
|
||||
type: 'boolean',
|
||||
required: true,
|
||||
acceptableCriteria: 'Must be true',
|
||||
weight: 30,
|
||||
isCritical: true,
|
||||
},
|
||||
{
|
||||
id: 'labeling_accuracy',
|
||||
category: 'labeling',
|
||||
description: 'Label accuracy and placement',
|
||||
spanishDescription: 'Precisión y colocación de etiquetas',
|
||||
type: 'scale',
|
||||
required: true,
|
||||
minValue: 1,
|
||||
maxValue: 5,
|
||||
acceptableCriteria: 'Score 4 or higher',
|
||||
weight: 25,
|
||||
isCritical: true,
|
||||
},
|
||||
{
|
||||
id: 'package_appearance',
|
||||
category: 'appearance',
|
||||
description: 'Overall package appearance',
|
||||
spanishDescription: 'Apariencia general del envase',
|
||||
type: 'scale',
|
||||
required: true,
|
||||
minValue: 1,
|
||||
maxValue: 5,
|
||||
acceptableCriteria: 'Score 3 or higher',
|
||||
weight: 20,
|
||||
isCritical: false,
|
||||
},
|
||||
{
|
||||
id: 'barcode_readability',
|
||||
category: 'labeling',
|
||||
description: 'Barcode readability',
|
||||
spanishDescription: 'Legibilidad del código de barras',
|
||||
type: 'boolean',
|
||||
required: true,
|
||||
acceptableCriteria: 'Must be true',
|
||||
weight: 25,
|
||||
isCritical: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
const STATUS_COLORS = {
|
||||
[QualityCheckStatus.SCHEDULED]: 'bg-[var(--color-info)]/10 text-[var(--color-info)]',
|
||||
[QualityCheckStatus.IN_PROGRESS]: 'bg-yellow-100 text-yellow-800',
|
||||
[QualityCheckStatus.PASSED]: 'bg-[var(--color-success)]/10 text-[var(--color-success)]',
|
||||
[QualityCheckStatus.FAILED]: 'bg-[var(--color-error)]/10 text-[var(--color-error)]',
|
||||
[QualityCheckStatus.REQUIRES_REVIEW]: 'bg-[var(--color-primary)]/10 text-[var(--color-primary)]',
|
||||
[QualityCheckStatus.CANCELLED]: 'bg-[var(--bg-tertiary)] text-[var(--text-primary)]',
|
||||
};
|
||||
|
||||
export const QualityControl: React.FC<QualityControlProps> = ({
|
||||
className = '',
|
||||
batchId,
|
||||
checkType,
|
||||
onQualityCheckCompleted,
|
||||
onCorrectiveActionRequired,
|
||||
}) => {
|
||||
const [qualityChecks, setQualityChecks] = useState<QualityCheckResponse[]>([]);
|
||||
const [selectedTemplate, setSelectedTemplate] = useState<QualityCheckTemplate | null>(null);
|
||||
const [activeCheck, setActiveCheck] = useState<QualityCheck | null>(null);
|
||||
const [inspectionResults, setInspectionResults] = useState<Record<string, QualityInspectionResult>>({});
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [isInspectionModalOpen, setIsInspectionModalOpen] = useState(false);
|
||||
const [uploadedPhotos, setUploadedPhotos] = useState<Record<string, File>>({});
|
||||
const [currentInspector, setCurrentInspector] = useState<string>('inspector-1');
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const loadQualityChecks = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const params: any = {};
|
||||
if (batchId) params.batch_id = batchId;
|
||||
if (checkType) params.check_type = checkType;
|
||||
|
||||
const response = await productionService.getQualityChecks(params);
|
||||
|
||||
if (response.success && response.data) {
|
||||
setQualityChecks(response.data.items || []);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading quality checks:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [batchId, checkType]);
|
||||
|
||||
const startQualityCheck = (template: QualityCheckTemplate, batch?: any) => {
|
||||
setSelectedTemplate(template);
|
||||
setInspectionResults({});
|
||||
setUploadedPhotos({});
|
||||
setIsInspectionModalOpen(true);
|
||||
|
||||
// Initialize empty results for all criteria
|
||||
template.criteria.forEach(criterion => {
|
||||
setInspectionResults(prev => ({
|
||||
...prev,
|
||||
[criterion.id]: {
|
||||
checklistId: criterion.id,
|
||||
value: criterion.type === 'boolean' ? false : criterion.type === 'numeric' ? 0 : '',
|
||||
timestamp: new Date().toISOString(),
|
||||
inspector: currentInspector,
|
||||
}
|
||||
}));
|
||||
});
|
||||
};
|
||||
|
||||
const updateInspectionResult = (criterionId: string, value: string | number | boolean, notes?: string) => {
|
||||
setInspectionResults(prev => ({
|
||||
...prev,
|
||||
[criterionId]: {
|
||||
...prev[criterionId],
|
||||
value,
|
||||
notes,
|
||||
timestamp: new Date().toISOString(),
|
||||
}
|
||||
}));
|
||||
};
|
||||
|
||||
const handlePhotoUpload = (criterionId: string, file: File) => {
|
||||
setUploadedPhotos(prev => ({
|
||||
...prev,
|
||||
[criterionId]: file,
|
||||
}));
|
||||
|
||||
// Update inspection result with photo reference
|
||||
setInspectionResults(prev => ({
|
||||
...prev,
|
||||
[criterionId]: {
|
||||
...prev[criterionId],
|
||||
photo: file,
|
||||
}
|
||||
}));
|
||||
};
|
||||
|
||||
const calculateOverallScore = (): number => {
|
||||
if (!selectedTemplate) return 0;
|
||||
|
||||
let totalScore = 0;
|
||||
let totalWeight = 0;
|
||||
|
||||
selectedTemplate.criteria.forEach(criterion => {
|
||||
const result = inspectionResults[criterion.id];
|
||||
if (result) {
|
||||
let score = 0;
|
||||
|
||||
if (criterion.type === 'boolean') {
|
||||
score = result.value ? 100 : 0;
|
||||
} else if (criterion.type === 'scale') {
|
||||
const numValue = Number(result.value);
|
||||
score = (numValue / (criterion.maxValue || 5)) * 100;
|
||||
} else if (criterion.type === 'numeric') {
|
||||
// For numeric values, assume pass/fail based on acceptable range
|
||||
score = 100; // Simplified - would need more complex logic
|
||||
}
|
||||
|
||||
totalScore += score * criterion.weight;
|
||||
totalWeight += criterion.weight;
|
||||
}
|
||||
});
|
||||
|
||||
return totalWeight > 0 ? totalScore / totalWeight : 0;
|
||||
};
|
||||
|
||||
const checkCriticalFailures = (): string[] => {
|
||||
if (!selectedTemplate) return [];
|
||||
|
||||
const failures: string[] = [];
|
||||
|
||||
selectedTemplate.criteria.forEach(criterion => {
|
||||
if (criterion.isCritical) {
|
||||
const result = inspectionResults[criterion.id];
|
||||
if (result) {
|
||||
if (criterion.type === 'boolean' && !result.value) {
|
||||
failures.push(criterion.spanishDescription);
|
||||
} else if (criterion.type === 'scale' && Number(result.value) < 3) {
|
||||
failures.push(criterion.spanishDescription);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return failures;
|
||||
};
|
||||
|
||||
const completeQualityCheck = async () => {
|
||||
if (!selectedTemplate) return;
|
||||
|
||||
const overallScore = calculateOverallScore();
|
||||
const criticalFailures = checkCriticalFailures();
|
||||
const passed = overallScore >= selectedTemplate.passThreshold && criticalFailures.length === 0;
|
||||
|
||||
const checkData = {
|
||||
status: passed ? QualityCheckStatus.PASSED : QualityCheckStatus.FAILED,
|
||||
results: {
|
||||
overallScore,
|
||||
criticalFailures,
|
||||
individualResults: inspectionResults,
|
||||
photos: Object.keys(uploadedPhotos),
|
||||
},
|
||||
notes: `Inspección completada. Puntuación: ${overallScore.toFixed(1)}%`,
|
||||
corrective_actions: criticalFailures.length > 0 ? [
|
||||
`Fallas críticas encontradas: ${criticalFailures.join(', ')}`,
|
||||
'Revisar proceso de producción',
|
||||
'Re-entrenar personal si es necesario'
|
||||
] : undefined,
|
||||
};
|
||||
|
||||
try {
|
||||
// This would be the actual check ID from the created quality check
|
||||
const mockCheckId = 'check-id';
|
||||
const response = await productionService.completeQualityCheck(mockCheckId, checkData);
|
||||
|
||||
if (response.success) {
|
||||
if (onQualityCheckCompleted) {
|
||||
onQualityCheckCompleted(response.data as unknown as QualityCheck);
|
||||
}
|
||||
|
||||
if (!passed && checkData.corrective_actions && onCorrectiveActionRequired) {
|
||||
onCorrectiveActionRequired(response.data as unknown as QualityCheck, checkData.corrective_actions);
|
||||
}
|
||||
|
||||
setIsInspectionModalOpen(false);
|
||||
setSelectedTemplate(null);
|
||||
await loadQualityChecks();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error completing quality check:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const renderInspectionForm = () => {
|
||||
if (!selectedTemplate) return null;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="bg-[var(--color-info)]/5 border border-[var(--color-info)]/20 p-4 rounded-lg">
|
||||
<h3 className="font-semibold text-blue-900 mb-2">{selectedTemplate.spanishName}</h3>
|
||||
<p className="text-sm text-[var(--color-info)]">
|
||||
Umbral de aprobación: {selectedTemplate.passThreshold}%
|
||||
</p>
|
||||
{selectedTemplate.criticalPoints.length > 0 && (
|
||||
<p className="text-sm text-[var(--color-info)] mt-1">
|
||||
Puntos críticos: {selectedTemplate.criticalPoints.length}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
{selectedTemplate.criteria.map((criterion) => (
|
||||
<Card key={criterion.id} className={`p-4 ${criterion.isCritical ? 'border-orange-200 bg-orange-50' : ''}`}>
|
||||
<div className="flex justify-between items-start mb-3">
|
||||
<div className="flex-1">
|
||||
<h4 className="font-medium text-[var(--text-primary)]">{criterion.spanishDescription}</h4>
|
||||
<p className="text-sm text-[var(--text-secondary)] mt-1">{criterion.acceptableCriteria}</p>
|
||||
{criterion.isCritical && (
|
||||
<Badge className="bg-[var(--color-primary)]/10 text-[var(--color-primary)] text-xs mt-1">
|
||||
Punto Crítico
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<span className="text-sm text-[var(--text-tertiary)]">Peso: {criterion.weight}%</span>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
{criterion.type === 'boolean' && (
|
||||
<div className="flex gap-4">
|
||||
<label className="flex items-center">
|
||||
<input
|
||||
type="radio"
|
||||
name={criterion.id}
|
||||
value="true"
|
||||
checked={inspectionResults[criterion.id]?.value === true}
|
||||
onChange={() => updateInspectionResult(criterion.id, true)}
|
||||
className="mr-2"
|
||||
/>
|
||||
Sí / Pasa
|
||||
</label>
|
||||
<label className="flex items-center">
|
||||
<input
|
||||
type="radio"
|
||||
name={criterion.id}
|
||||
value="false"
|
||||
checked={inspectionResults[criterion.id]?.value === false}
|
||||
onChange={() => updateInspectionResult(criterion.id, false)}
|
||||
className="mr-2"
|
||||
/>
|
||||
No / Falla
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{criterion.type === 'scale' && (
|
||||
<div className="flex items-center gap-4">
|
||||
<span className="text-sm">1</span>
|
||||
<input
|
||||
type="range"
|
||||
min={criterion.minValue || 1}
|
||||
max={criterion.maxValue || 5}
|
||||
step="1"
|
||||
value={inspectionResults[criterion.id]?.value || criterion.minValue || 1}
|
||||
onChange={(e) => updateInspectionResult(criterion.id, parseInt(e.target.value))}
|
||||
className="flex-1"
|
||||
/>
|
||||
<span className="text-sm">{criterion.maxValue || 5}</span>
|
||||
<span className="min-w-8 text-center font-medium">
|
||||
{inspectionResults[criterion.id]?.value || criterion.minValue || 1}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{criterion.type === 'numeric' && (
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
type="number"
|
||||
min={criterion.minValue}
|
||||
max={criterion.maxValue}
|
||||
step="0.1"
|
||||
value={inspectionResults[criterion.id]?.value || ''}
|
||||
onChange={(e) => updateInspectionResult(criterion.id, parseFloat(e.target.value) || 0)}
|
||||
className="w-24"
|
||||
/>
|
||||
{criterion.unit && <span className="text-sm text-[var(--text-secondary)]">{criterion.unit}</span>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Input
|
||||
placeholder="Notas adicionales (opcional)"
|
||||
value={inspectionResults[criterion.id]?.notes || ''}
|
||||
onChange={(e) => {
|
||||
const currentResult = inspectionResults[criterion.id];
|
||||
if (currentResult) {
|
||||
updateInspectionResult(criterion.id, currentResult.value, e.target.value);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
{selectedTemplate.requiresPhotos && (
|
||||
<div>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
onChange={(e) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) {
|
||||
handlePhotoUpload(criterion.id, file);
|
||||
}
|
||||
}}
|
||||
className="hidden"
|
||||
/>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
>
|
||||
📸 {uploadedPhotos[criterion.id] ? 'Cambiar foto' : 'Tomar foto'}
|
||||
</Button>
|
||||
{uploadedPhotos[criterion.id] && (
|
||||
<p className="text-sm text-[var(--color-success)] mt-1">
|
||||
✓ Foto capturada: {uploadedPhotos[criterion.id].name}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<Card className="p-4 bg-[var(--bg-secondary)]">
|
||||
<div className="flex justify-between items-center mb-2">
|
||||
<span className="font-medium">Puntuación general</span>
|
||||
<span className="text-2xl font-bold text-[var(--color-info)]">
|
||||
{calculateOverallScore().toFixed(1)}%
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="w-full bg-[var(--bg-quaternary)] rounded-full h-3 mb-2">
|
||||
<div
|
||||
className={`h-3 rounded-full transition-all duration-500 ${
|
||||
calculateOverallScore() >= selectedTemplate.passThreshold
|
||||
? 'bg-green-500'
|
||||
: 'bg-red-500'
|
||||
}`}
|
||||
style={{ width: `${calculateOverallScore()}%` }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between text-sm text-[var(--text-secondary)]">
|
||||
<span>Umbral: {selectedTemplate.passThreshold}%</span>
|
||||
<span className={
|
||||
calculateOverallScore() >= selectedTemplate.passThreshold
|
||||
? 'text-[var(--color-success)] font-medium'
|
||||
: 'text-[var(--color-error)] font-medium'
|
||||
}>
|
||||
{calculateOverallScore() >= selectedTemplate.passThreshold ? '✓ APROBADO' : '✗ REPROBADO'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{checkCriticalFailures().length > 0 && (
|
||||
<div className="mt-3 p-2 bg-[var(--color-error)]/10 border border-red-200 rounded">
|
||||
<p className="text-sm font-medium text-[var(--color-error)]">Fallas críticas detectadas:</p>
|
||||
<ul className="text-sm text-[var(--color-error)] mt-1">
|
||||
{checkCriticalFailures().map((failure, index) => (
|
||||
<li key={index}>• {failure}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const renderQualityChecksTable = () => {
|
||||
const columns = [
|
||||
{ key: 'batch', label: 'Lote', sortable: true },
|
||||
{ key: 'type', label: 'Tipo de control', sortable: true },
|
||||
{ key: 'status', label: 'Estado', sortable: true },
|
||||
{ key: 'inspector', label: 'Inspector', sortable: false },
|
||||
{ key: 'scheduled_date', label: 'Fecha programada', sortable: true },
|
||||
{ key: 'completed_date', label: 'Fecha completada', sortable: true },
|
||||
{ key: 'actions', label: 'Acciones', sortable: false },
|
||||
];
|
||||
|
||||
const data = qualityChecks.map(check => ({
|
||||
id: check.id,
|
||||
batch: `#${check.batch?.batch_number || 'N/A'}`,
|
||||
type: QUALITY_CHECK_TEMPLATES[check.check_type]?.spanishName || check.check_type,
|
||||
status: (
|
||||
<Badge className={STATUS_COLORS[check.status]}>
|
||||
{check.status === QualityCheckStatus.SCHEDULED && 'Programado'}
|
||||
{check.status === QualityCheckStatus.IN_PROGRESS && 'En progreso'}
|
||||
{check.status === QualityCheckStatus.PASSED && 'Aprobado'}
|
||||
{check.status === QualityCheckStatus.FAILED && 'Reprobado'}
|
||||
{check.status === QualityCheckStatus.REQUIRES_REVIEW && 'Requiere revisión'}
|
||||
</Badge>
|
||||
),
|
||||
inspector: check.inspector || 'No asignado',
|
||||
scheduled_date: check.scheduled_date
|
||||
? new Date(check.scheduled_date).toLocaleDateString('es-ES')
|
||||
: '-',
|
||||
completed_date: check.completed_date
|
||||
? new Date(check.completed_date).toLocaleDateString('es-ES')
|
||||
: '-',
|
||||
actions: (
|
||||
<div className="flex gap-2">
|
||||
{check.status === QualityCheckStatus.SCHEDULED && (
|
||||
<Button
|
||||
variant="primary"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
const template = QUALITY_CHECK_TEMPLATES[check.check_type];
|
||||
if (template) {
|
||||
startQualityCheck(template);
|
||||
}
|
||||
}}
|
||||
>
|
||||
Iniciar
|
||||
</Button>
|
||||
)}
|
||||
<Button variant="outline" size="sm">
|
||||
Ver detalles
|
||||
</Button>
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
return <Table columns={columns} data={data} />;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`space-y-6 ${className}`}>
|
||||
<div className="flex justify-between items-center">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold text-[var(--text-primary)]">Control de Calidad</h2>
|
||||
<p className="text-[var(--text-secondary)]">Gestiona las inspecciones de calidad y cumplimiento</p>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Select
|
||||
value={currentInspector}
|
||||
onChange={(e) => setCurrentInspector(e.target.value)}
|
||||
className="w-48"
|
||||
>
|
||||
<option value="inspector-1">María García (Inspector)</option>
|
||||
<option value="inspector-2">Juan López (Supervisor)</option>
|
||||
<option value="inspector-3">Ana Martín (Jefe Calidad)</option>
|
||||
</Select>
|
||||
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={() => {
|
||||
// Show modal to select quality check type
|
||||
const template = QUALITY_CHECK_TEMPLATES.visual_inspection;
|
||||
startQualityCheck(template);
|
||||
}}
|
||||
>
|
||||
Nueva inspección
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Quick Start Templates */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
{Object.values(QUALITY_CHECK_TEMPLATES).map((template) => (
|
||||
<Card
|
||||
key={template.id}
|
||||
className="p-4 cursor-pointer hover:shadow-md transition-shadow border-2 border-transparent hover:border-[var(--color-info)]/20"
|
||||
onClick={() => startQualityCheck(template)}
|
||||
>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h3 className="font-semibold text-[var(--text-primary)]">{template.spanishName}</h3>
|
||||
{template.requiresPhotos && (
|
||||
<span className="text-sm bg-[var(--color-info)]/10 text-[var(--color-info)] px-2 py-1 rounded">
|
||||
📸 Fotos
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<p className="text-sm text-[var(--text-secondary)] mb-3">
|
||||
{template.criteria.length} criterios • Umbral: {template.passThreshold}%
|
||||
</p>
|
||||
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{template.productTypes.map((type) => (
|
||||
<Badge key={type} className="text-xs bg-[var(--bg-tertiary)] text-[var(--text-secondary)]">
|
||||
{type === 'pan' && 'Pan'}
|
||||
{type === 'bolleria' && 'Bollería'}
|
||||
{type === 'reposteria' && 'Repostería'}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{template.criticalPoints.length > 0 && (
|
||||
<p className="text-xs text-[var(--color-primary)] mt-2">
|
||||
{template.criticalPoints.length} puntos críticos
|
||||
</p>
|
||||
)}
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Quality Checks Table */}
|
||||
<Card className="p-6">
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<h3 className="text-lg font-semibold">Controles de calidad recientes</h3>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Select className="w-40">
|
||||
<option value="all">Todos los estados</option>
|
||||
<option value={QualityCheckStatus.SCHEDULED}>Programados</option>
|
||||
<option value={QualityCheckStatus.IN_PROGRESS}>En progreso</option>
|
||||
<option value={QualityCheckStatus.PASSED}>Aprobados</option>
|
||||
<option value={QualityCheckStatus.FAILED}>Reprobados</option>
|
||||
</Select>
|
||||
|
||||
<Button variant="outline" onClick={loadQualityChecks}>
|
||||
Actualizar
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="flex justify-center py-8">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
|
||||
</div>
|
||||
) : (
|
||||
renderQualityChecksTable()
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* Quality Check Modal */}
|
||||
<Modal
|
||||
isOpen={isInspectionModalOpen}
|
||||
onClose={() => {
|
||||
setIsInspectionModalOpen(false);
|
||||
setSelectedTemplate(null);
|
||||
}}
|
||||
title={`Control de Calidad: ${selectedTemplate?.spanishName}`}
|
||||
size="lg"
|
||||
>
|
||||
{renderInspectionForm()}
|
||||
|
||||
<div className="flex justify-end gap-2 pt-6 border-t">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setIsInspectionModalOpen(false);
|
||||
setSelectedTemplate(null);
|
||||
}}
|
||||
>
|
||||
Cancelar
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={completeQualityCheck}
|
||||
disabled={!selectedTemplate || Object.keys(inspectionResults).length === 0}
|
||||
>
|
||||
Completar inspección
|
||||
</Button>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
{/* Stats Cards */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||
<Card className="p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-[var(--text-secondary)]">Tasa de aprobación</p>
|
||||
<p className="text-2xl font-bold text-[var(--color-success)]">94.2%</p>
|
||||
</div>
|
||||
<span className="text-2xl">✅</span>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className="p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-[var(--text-secondary)]">Controles pendientes</p>
|
||||
<p className="text-2xl font-bold text-[var(--color-primary)]">7</p>
|
||||
</div>
|
||||
<span className="text-2xl">⏳</span>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className="p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-[var(--text-secondary)]">Fallas críticas</p>
|
||||
<p className="text-2xl font-bold text-[var(--color-error)]">2</p>
|
||||
</div>
|
||||
<span className="text-2xl">🚨</span>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className="p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-[var(--text-secondary)]">Controles hoy</p>
|
||||
<p className="text-2xl font-bold text-[var(--color-info)]">23</p>
|
||||
</div>
|
||||
<span className="text-2xl">📋</span>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default QualityControl;
|
||||
@@ -1,467 +0,0 @@
|
||||
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 { Tabs, TabsList, TabsTrigger, TabsContent } from '../../ui/Tabs';
|
||||
import {
|
||||
CheckCircle,
|
||||
AlertTriangle,
|
||||
TrendingUp,
|
||||
TrendingDown,
|
||||
Target,
|
||||
Activity,
|
||||
BarChart3,
|
||||
Camera,
|
||||
FileCheck,
|
||||
Clock,
|
||||
Users,
|
||||
Package
|
||||
} from 'lucide-react';
|
||||
import { useCurrentTenant } from '../../../stores/tenant.store';
|
||||
import { useProductionDashboard } from '../../../api';
|
||||
|
||||
export interface QualityMetrics {
|
||||
totalChecks: number;
|
||||
passedChecks: number;
|
||||
failedChecks: number;
|
||||
averageScore: number;
|
||||
passRate: number;
|
||||
trendsData: Array<{
|
||||
date: string;
|
||||
score: number;
|
||||
passRate: number;
|
||||
checks: number;
|
||||
}>;
|
||||
byProduct: Array<{
|
||||
productName: string;
|
||||
checks: number;
|
||||
averageScore: number;
|
||||
passRate: number;
|
||||
topDefects: string[];
|
||||
}>;
|
||||
byCategory: Array<{
|
||||
category: string;
|
||||
checks: number;
|
||||
averageScore: number;
|
||||
issues: number;
|
||||
}>;
|
||||
recentChecks: Array<{
|
||||
id: string;
|
||||
batchId: string;
|
||||
productName: string;
|
||||
checkType: string;
|
||||
score: number;
|
||||
status: 'passed' | 'failed' | 'warning';
|
||||
timestamp: string;
|
||||
inspector: string;
|
||||
defects: string[];
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface QualityDashboardProps {
|
||||
className?: string;
|
||||
data?: QualityMetrics;
|
||||
dateRange?: {
|
||||
startDate: string;
|
||||
endDate: string;
|
||||
};
|
||||
onCreateCheck?: () => void;
|
||||
onViewCheck?: (checkId: string) => void;
|
||||
onViewTrends?: () => void;
|
||||
}
|
||||
|
||||
const QualityDashboard: React.FC<QualityDashboardProps> = ({
|
||||
className,
|
||||
data,
|
||||
dateRange,
|
||||
onCreateCheck,
|
||||
onViewCheck,
|
||||
onViewTrends
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const [activeTab, setActiveTab] = useState('overview');
|
||||
const currentTenant = useCurrentTenant();
|
||||
const tenantId = currentTenant?.id || '';
|
||||
|
||||
const {
|
||||
data: dashboardData,
|
||||
isLoading,
|
||||
error
|
||||
} = useProductionDashboard(tenantId);
|
||||
|
||||
const qualityData = useMemo((): QualityMetrics => {
|
||||
if (data) return data;
|
||||
|
||||
// Mock data for demonstration
|
||||
return {
|
||||
totalChecks: 156,
|
||||
passedChecks: 142,
|
||||
failedChecks: 14,
|
||||
averageScore: 8.3,
|
||||
passRate: 91.0,
|
||||
trendsData: [
|
||||
{ date: '2024-01-17', score: 8.1, passRate: 89, checks: 22 },
|
||||
{ date: '2024-01-18', score: 8.4, passRate: 92, checks: 25 },
|
||||
{ date: '2024-01-19', score: 8.2, passRate: 90, checks: 24 },
|
||||
{ date: '2024-01-20', score: 8.5, passRate: 94, checks: 23 },
|
||||
{ date: '2024-01-21', score: 8.3, passRate: 91, checks: 26 },
|
||||
{ date: '2024-01-22', score: 8.6, passRate: 95, checks: 21 },
|
||||
{ date: '2024-01-23', score: 8.3, passRate: 91, checks: 15 }
|
||||
],
|
||||
byProduct: [
|
||||
{
|
||||
productName: 'Pan de Molde Integral',
|
||||
checks: 35,
|
||||
averageScore: 8.7,
|
||||
passRate: 94.3,
|
||||
topDefects: ['Forma irregular', 'Color desigual']
|
||||
},
|
||||
{
|
||||
productName: 'Croissants de Mantequilla',
|
||||
checks: 28,
|
||||
averageScore: 8.1,
|
||||
passRate: 89.3,
|
||||
topDefects: ['Textura dura', 'Tama<6D>o inconsistente']
|
||||
},
|
||||
{
|
||||
productName: 'Baguettes Tradicionales',
|
||||
checks: 22,
|
||||
averageScore: 8.5,
|
||||
passRate: 95.5,
|
||||
topDefects: ['Corteza muy oscura']
|
||||
}
|
||||
],
|
||||
byCategory: [
|
||||
{ category: 'Apariencia Visual', checks: 156, averageScore: 8.4, issues: 12 },
|
||||
{ category: 'Textura', checks: 142, averageScore: 8.2, issues: 15 },
|
||||
{ category: 'Sabor', checks: 98, averageScore: 8.6, issues: 8 },
|
||||
{ category: 'Dimensiones', checks: 156, averageScore: 8.1, issues: 18 },
|
||||
{ category: 'Peso', checks: 156, averageScore: 8.9, issues: 4 }
|
||||
],
|
||||
recentChecks: [
|
||||
{
|
||||
id: '1',
|
||||
batchId: 'PROD-2024-0123-001',
|
||||
productName: 'Pan de Molde Integral',
|
||||
checkType: 'Inspecci<63>n Visual',
|
||||
score: 8.5,
|
||||
status: 'passed',
|
||||
timestamp: '2024-01-23T14:30:00Z',
|
||||
inspector: 'Mar<61>a Gonz<6E>lez',
|
||||
defects: []
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
batchId: 'PROD-2024-0123-002',
|
||||
productName: 'Croissants de Mantequilla',
|
||||
checkType: 'Control de Calidad',
|
||||
score: 7.2,
|
||||
status: 'warning',
|
||||
timestamp: '2024-01-23T13:45:00Z',
|
||||
inspector: 'Carlos Rodr<64>guez',
|
||||
defects: ['Textura ligeramente dura']
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
batchId: 'PROD-2024-0123-003',
|
||||
productName: 'Baguettes Tradicionales',
|
||||
checkType: 'Inspecci<63>n Final',
|
||||
score: 6.8,
|
||||
status: 'failed',
|
||||
timestamp: '2024-01-23T12:15:00Z',
|
||||
inspector: 'Ana Mart<72>n',
|
||||
defects: ['Corteza muy oscura', 'Forma irregular']
|
||||
}
|
||||
]
|
||||
};
|
||||
}, [data]);
|
||||
|
||||
const getStatusColor = (status: 'passed' | 'failed' | 'warning') => {
|
||||
const colors = {
|
||||
passed: 'success',
|
||||
failed: 'error',
|
||||
warning: 'warning'
|
||||
};
|
||||
return colors[status] as 'success' | 'error' | 'warning';
|
||||
};
|
||||
|
||||
const getStatusIcon = (status: 'passed' | 'failed' | 'warning') => {
|
||||
const icons = {
|
||||
passed: CheckCircle,
|
||||
failed: AlertTriangle,
|
||||
warning: AlertTriangle
|
||||
};
|
||||
return icons[status];
|
||||
};
|
||||
|
||||
const formatDateTime = (timestamp: string) => {
|
||||
return new Date(timestamp).toLocaleString('es-ES', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
});
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Card className={className}>
|
||||
<CardHeader>
|
||||
<h3 className="text-lg font-semibold text-[var(--text-primary)]">
|
||||
{t('quality.dashboard.title', 'Quality Dashboard')}
|
||||
</h3>
|
||||
</CardHeader>
|
||||
<CardBody>
|
||||
<div className="animate-pulse space-y-4">
|
||||
<div className="grid grid-cols-4 gap-4">
|
||||
<div className="h-20 bg-[var(--bg-secondary)] rounded"></div>
|
||||
<div className="h-20 bg-[var(--bg-secondary)] rounded"></div>
|
||||
<div className="h-20 bg-[var(--bg-secondary)] rounded"></div>
|
||||
<div className="h-20 bg-[var(--bg-secondary)] rounded"></div>
|
||||
</div>
|
||||
<div className="h-64 bg-[var(--bg-secondary)] rounded"></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('quality.dashboard.error', 'Error loading quality data')}
|
||||
</p>
|
||||
</CardBody>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
const qualityStats = [
|
||||
{
|
||||
title: t('quality.stats.total_checks', 'Total Checks'),
|
||||
value: qualityData.totalChecks,
|
||||
icon: FileCheck,
|
||||
variant: 'default' as const,
|
||||
subtitle: t('quality.stats.last_7_days', 'Last 7 days')
|
||||
},
|
||||
{
|
||||
title: t('quality.stats.pass_rate', 'Pass Rate'),
|
||||
value: `${qualityData.passRate.toFixed(1)}%`,
|
||||
icon: CheckCircle,
|
||||
variant: qualityData.passRate >= 95 ? 'success' as const :
|
||||
qualityData.passRate >= 90 ? 'warning' as const : 'error' as const,
|
||||
trend: {
|
||||
value: 2.3,
|
||||
direction: 'up' as const,
|
||||
label: t('quality.trends.vs_last_week', 'vs last week')
|
||||
},
|
||||
subtitle: `${qualityData.passedChecks}/${qualityData.totalChecks} ${t('quality.stats.passed', 'passed')}`
|
||||
},
|
||||
{
|
||||
title: t('quality.stats.average_score', 'Average Score'),
|
||||
value: qualityData.averageScore.toFixed(1),
|
||||
icon: Target,
|
||||
variant: qualityData.averageScore >= 8.5 ? 'success' as const :
|
||||
qualityData.averageScore >= 7.5 ? 'warning' as const : 'error' as const,
|
||||
subtitle: t('quality.stats.out_of_10', 'out of 10')
|
||||
},
|
||||
{
|
||||
title: t('quality.stats.failed_checks', 'Failed Checks'),
|
||||
value: qualityData.failedChecks,
|
||||
icon: AlertTriangle,
|
||||
variant: qualityData.failedChecks === 0 ? 'success' as const :
|
||||
qualityData.failedChecks <= 5 ? 'warning' as const : 'error' as const,
|
||||
subtitle: t('quality.stats.requiring_action', 'Requiring action')
|
||||
}
|
||||
];
|
||||
|
||||
return (
|
||||
<Card className={className}>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-[var(--text-primary)]">
|
||||
{t('quality.dashboard.title', 'Quality Dashboard')}
|
||||
</h3>
|
||||
<p className="text-sm text-[var(--text-secondary)]">
|
||||
{t('quality.dashboard.subtitle', 'Monitor quality metrics and trends')}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
onClick={onCreateCheck}
|
||||
variant="primary"
|
||||
size="sm"
|
||||
>
|
||||
<Camera className="w-4 h-4 mr-2" />
|
||||
{t('quality.actions.new_check', 'New Check')}
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardBody className="space-y-6">
|
||||
{/* Key Metrics */}
|
||||
<StatsGrid
|
||||
stats={qualityStats}
|
||||
columns={4}
|
||||
gap="md"
|
||||
/>
|
||||
|
||||
{/* Detailed Analysis Tabs */}
|
||||
<Tabs value={activeTab} onValueChange={setActiveTab}>
|
||||
<TabsList className="grid w-full grid-cols-4">
|
||||
<TabsTrigger value="overview">
|
||||
{t('quality.tabs.overview', 'Overview')}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="products">
|
||||
{t('quality.tabs.by_product', 'By Product')}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="categories">
|
||||
{t('quality.tabs.categories', 'Categories')}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="recent">
|
||||
{t('quality.tabs.recent', 'Recent')}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="overview" className="space-y-4">
|
||||
{/* Trends Chart Placeholder */}
|
||||
<div className="p-6 bg-[var(--bg-secondary)] rounded-lg">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h4 className="font-medium text-[var(--text-primary)]">
|
||||
{t('quality.charts.weekly_trends', 'Weekly Quality Trends')}
|
||||
</h4>
|
||||
<Button variant="outline" size="sm" onClick={onViewTrends}>
|
||||
<BarChart3 className="w-4 h-4 mr-2" />
|
||||
{t('quality.actions.view_trends', 'View Trends')}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="h-32 flex items-center justify-center border-2 border-dashed border-[var(--border-primary)] rounded">
|
||||
<p className="text-[var(--text-tertiary)]">
|
||||
{t('quality.charts.placeholder', 'Quality trends chart will be displayed here')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="products" className="space-y-4">
|
||||
<div className="space-y-3">
|
||||
{qualityData.byProduct.map((product, index) => (
|
||||
<div key={index} className="p-4 bg-[var(--bg-secondary)] rounded-lg">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<h4 className="font-medium text-[var(--text-primary)]">
|
||||
{product.productName}
|
||||
</h4>
|
||||
<Badge variant={product.passRate >= 95 ? 'success' : product.passRate >= 90 ? 'warning' : 'error'}>
|
||||
{product.passRate.toFixed(1)}% {t('quality.stats.pass_rate', 'pass rate')}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-4 text-sm">
|
||||
<div>
|
||||
<span className="text-[var(--text-secondary)]">{t('quality.stats.checks', 'Checks')}: </span>
|
||||
<span className="font-medium">{product.checks}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-[var(--text-secondary)]">{t('quality.stats.avg_score', 'Avg Score')}: </span>
|
||||
<span className="font-medium">{product.averageScore.toFixed(1)}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-[var(--text-secondary)]">{t('quality.stats.top_defects', 'Top Defects')}: </span>
|
||||
<span className="font-medium">{product.topDefects.join(', ')}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="categories" className="space-y-4">
|
||||
<div className="space-y-3">
|
||||
{qualityData.byCategory.map((category, index) => (
|
||||
<div key={index} className="flex items-center justify-between p-4 bg-[var(--bg-secondary)] rounded-lg">
|
||||
<div>
|
||||
<h4 className="font-medium text-[var(--text-primary)]">
|
||||
{category.category}
|
||||
</h4>
|
||||
<div className="flex items-center space-x-4 text-sm text-[var(--text-secondary)]">
|
||||
<span>{category.checks} {t('quality.stats.checks', 'checks')}</span>
|
||||
<span>{category.averageScore.toFixed(1)} {t('quality.stats.avg_score', 'avg score')}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<div className={`text-lg font-bold ${category.issues === 0 ? 'text-green-600' : category.issues <= 5 ? 'text-orange-600' : 'text-red-600'}`}>
|
||||
{category.issues}
|
||||
</div>
|
||||
<div className="text-sm text-[var(--text-secondary)]">
|
||||
{t('quality.stats.issues', 'issues')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="recent" className="space-y-4">
|
||||
<div className="space-y-3">
|
||||
{qualityData.recentChecks.map((check) => {
|
||||
const StatusIcon = getStatusIcon(check.status);
|
||||
return (
|
||||
<div
|
||||
key={check.id}
|
||||
className="p-4 bg-[var(--bg-secondary)] rounded-lg hover:bg-[var(--bg-tertiary)] transition-colors cursor-pointer"
|
||||
onClick={() => onViewCheck?.(check.id)}
|
||||
>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="flex items-center space-x-3">
|
||||
<StatusIcon className={`w-5 h-5 ${
|
||||
check.status === 'passed' ? 'text-green-500' :
|
||||
check.status === 'warning' ? 'text-orange-500' : 'text-red-500'
|
||||
}`} />
|
||||
<div>
|
||||
<h4 className="font-medium text-[var(--text-primary)]">
|
||||
{check.productName}
|
||||
</h4>
|
||||
<p className="text-sm text-[var(--text-secondary)]">
|
||||
{check.checkType} " {check.batchId}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<div className="text-lg font-bold text-[var(--text-primary)]">
|
||||
{check.score.toFixed(1)}
|
||||
</div>
|
||||
<div className="text-sm text-[var(--text-secondary)]">
|
||||
{formatDateTime(check.timestamp)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Users className="w-4 h-4 text-[var(--text-tertiary)]" />
|
||||
<span className="text-[var(--text-secondary)]">{check.inspector}</span>
|
||||
</div>
|
||||
{check.defects.length > 0 && (
|
||||
<div className="flex items-center space-x-2">
|
||||
<AlertTriangle className="w-4 h-4 text-orange-500" />
|
||||
<span className="text-sm text-orange-600">
|
||||
{check.defects.length} {t('quality.stats.defects', 'defects')}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</CardBody>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default QualityDashboard;
|
||||
@@ -1,585 +0,0 @@
|
||||
import React, { useState, useCallback } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Card, CardHeader, CardBody } from '../../ui/Card';
|
||||
import { Button } from '../../ui/Button';
|
||||
import { Input } from '../../ui/Input';
|
||||
import { Textarea } from '../../ui';
|
||||
import { Badge } from '../../ui/Badge';
|
||||
import { Modal } from '../../ui/Modal';
|
||||
import { Tabs, TabsList, TabsTrigger, TabsContent } from '../../ui/Tabs';
|
||||
import {
|
||||
Camera,
|
||||
CheckCircle,
|
||||
XCircle,
|
||||
AlertTriangle,
|
||||
Upload,
|
||||
Star,
|
||||
Target,
|
||||
FileText,
|
||||
Clock,
|
||||
User,
|
||||
Package
|
||||
} from 'lucide-react';
|
||||
import { useCurrentTenant } from '../../../stores/tenant.store';
|
||||
|
||||
export interface InspectionCriteria {
|
||||
id: string;
|
||||
category: string;
|
||||
name: string;
|
||||
description: string;
|
||||
type: 'visual' | 'measurement' | 'taste' | 'texture' | 'temperature';
|
||||
required: boolean;
|
||||
weight: number;
|
||||
acceptableCriteria: string;
|
||||
minValue?: number;
|
||||
maxValue?: number;
|
||||
unit?: string;
|
||||
}
|
||||
|
||||
export interface InspectionResult {
|
||||
criteriaId: string;
|
||||
value: number | string | boolean;
|
||||
score: number;
|
||||
notes?: string;
|
||||
photos?: File[];
|
||||
pass: boolean;
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
export interface QualityInspectionData {
|
||||
batchId: string;
|
||||
productName: string;
|
||||
inspectionType: string;
|
||||
inspector: string;
|
||||
startTime: string;
|
||||
criteria: InspectionCriteria[];
|
||||
results: InspectionResult[];
|
||||
overallScore: number;
|
||||
overallPass: boolean;
|
||||
finalNotes: string;
|
||||
photos: File[];
|
||||
correctiveActions: string[];
|
||||
}
|
||||
|
||||
export interface QualityInspectionProps {
|
||||
className?: string;
|
||||
batchId?: string;
|
||||
productName?: string;
|
||||
inspectionType?: string;
|
||||
criteria?: InspectionCriteria[];
|
||||
onComplete?: (data: QualityInspectionData) => void;
|
||||
onCancel?: () => void;
|
||||
onSaveDraft?: (data: Partial<QualityInspectionData>) => void;
|
||||
}
|
||||
|
||||
const DEFAULT_CRITERIA: InspectionCriteria[] = [
|
||||
{
|
||||
id: 'color_uniformity',
|
||||
category: 'Visual',
|
||||
name: 'Color Uniformity',
|
||||
description: 'Evaluate the consistency of color across the product',
|
||||
type: 'visual',
|
||||
required: true,
|
||||
weight: 15,
|
||||
acceptableCriteria: 'Score 7 or higher',
|
||||
minValue: 1,
|
||||
maxValue: 10
|
||||
},
|
||||
{
|
||||
id: 'shape_integrity',
|
||||
category: 'Visual',
|
||||
name: 'Shape Integrity',
|
||||
description: 'Check if the product maintains its intended shape',
|
||||
type: 'visual',
|
||||
required: true,
|
||||
weight: 20,
|
||||
acceptableCriteria: 'Score 7 or higher',
|
||||
minValue: 1,
|
||||
maxValue: 10
|
||||
},
|
||||
{
|
||||
id: 'surface_texture',
|
||||
category: 'Texture',
|
||||
name: 'Surface Texture',
|
||||
description: 'Evaluate surface texture quality',
|
||||
type: 'texture',
|
||||
required: true,
|
||||
weight: 15,
|
||||
acceptableCriteria: 'Score 7 or higher',
|
||||
minValue: 1,
|
||||
maxValue: 10
|
||||
},
|
||||
{
|
||||
id: 'weight_accuracy',
|
||||
category: 'Measurement',
|
||||
name: 'Weight Accuracy',
|
||||
description: 'Measure actual weight vs target weight',
|
||||
type: 'measurement',
|
||||
required: true,
|
||||
weight: 20,
|
||||
acceptableCriteria: 'Within <20>5% of target',
|
||||
unit: 'g'
|
||||
},
|
||||
{
|
||||
id: 'internal_texture',
|
||||
category: 'Texture',
|
||||
name: 'Internal Texture',
|
||||
description: 'Evaluate crumb structure and texture',
|
||||
type: 'texture',
|
||||
required: false,
|
||||
weight: 15,
|
||||
acceptableCriteria: 'Score 7 or higher',
|
||||
minValue: 1,
|
||||
maxValue: 10
|
||||
},
|
||||
{
|
||||
id: 'taste_quality',
|
||||
category: 'Taste',
|
||||
name: 'Taste Quality',
|
||||
description: 'Overall flavor and taste assessment',
|
||||
type: 'taste',
|
||||
required: false,
|
||||
weight: 15,
|
||||
acceptableCriteria: 'Score 7 or higher',
|
||||
minValue: 1,
|
||||
maxValue: 10
|
||||
}
|
||||
];
|
||||
|
||||
const QualityInspection: React.FC<QualityInspectionProps> = ({
|
||||
className,
|
||||
batchId = 'PROD-2024-0123-001',
|
||||
productName = 'Pan de Molde Integral',
|
||||
inspectionType = 'Final Quality Check',
|
||||
criteria = DEFAULT_CRITERIA,
|
||||
onComplete,
|
||||
onCancel,
|
||||
onSaveDraft
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const currentTenant = useCurrentTenant();
|
||||
const [activeTab, setActiveTab] = useState('inspection');
|
||||
|
||||
const [inspectionData, setInspectionData] = useState<Partial<QualityInspectionData>>({
|
||||
batchId,
|
||||
productName,
|
||||
inspectionType,
|
||||
inspector: currentTenant?.name || 'Inspector',
|
||||
startTime: new Date().toISOString(),
|
||||
criteria,
|
||||
results: [],
|
||||
finalNotes: '',
|
||||
photos: [],
|
||||
correctiveActions: []
|
||||
});
|
||||
|
||||
const [currentCriteriaIndex, setCurrentCriteriaIndex] = useState(0);
|
||||
const [showPhotoModal, setShowPhotoModal] = useState(false);
|
||||
const [tempPhotos, setTempPhotos] = useState<File[]>([]);
|
||||
|
||||
const updateResult = useCallback((criteriaId: string, updates: Partial<InspectionResult>) => {
|
||||
setInspectionData(prev => {
|
||||
const existingResults = prev.results || [];
|
||||
const existingIndex = existingResults.findIndex(r => r.criteriaId === criteriaId);
|
||||
|
||||
let newResults;
|
||||
if (existingIndex >= 0) {
|
||||
newResults = [...existingResults];
|
||||
newResults[existingIndex] = { ...newResults[existingIndex], ...updates };
|
||||
} else {
|
||||
newResults = [...existingResults, {
|
||||
criteriaId,
|
||||
value: '',
|
||||
score: 0,
|
||||
pass: false,
|
||||
timestamp: new Date().toISOString(),
|
||||
...updates
|
||||
}];
|
||||
}
|
||||
|
||||
return { ...prev, results: newResults };
|
||||
});
|
||||
}, []);
|
||||
|
||||
const getCriteriaResult = useCallback((criteriaId: string): InspectionResult | undefined => {
|
||||
return inspectionData.results?.find(r => r.criteriaId === criteriaId);
|
||||
}, [inspectionData.results]);
|
||||
|
||||
const calculateOverallScore = useCallback((): number => {
|
||||
if (!inspectionData.results || inspectionData.results.length === 0) return 0;
|
||||
|
||||
const totalWeight = criteria.reduce((sum, c) => sum + c.weight, 0);
|
||||
const weightedScore = inspectionData.results.reduce((sum, result) => {
|
||||
const criterion = criteria.find(c => c.id === result.criteriaId);
|
||||
return sum + (result.score * (criterion?.weight || 0));
|
||||
}, 0);
|
||||
|
||||
return totalWeight > 0 ? weightedScore / totalWeight : 0;
|
||||
}, [inspectionData.results, criteria]);
|
||||
|
||||
const isInspectionComplete = useCallback((): boolean => {
|
||||
const requiredCriteria = criteria.filter(c => c.required);
|
||||
const completedRequired = requiredCriteria.filter(c =>
|
||||
inspectionData.results?.some(r => r.criteriaId === c.id)
|
||||
);
|
||||
return completedRequired.length === requiredCriteria.length;
|
||||
}, [criteria, inspectionData.results]);
|
||||
|
||||
const handlePhotoUpload = useCallback((event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const files = Array.from(event.target.files || []);
|
||||
setTempPhotos(prev => [...prev, ...files]);
|
||||
}, []);
|
||||
|
||||
const handleComplete = useCallback(() => {
|
||||
const overallScore = calculateOverallScore();
|
||||
const overallPass = overallScore >= 7.0; // Configurable threshold
|
||||
|
||||
const completedData: QualityInspectionData = {
|
||||
...inspectionData as QualityInspectionData,
|
||||
overallScore,
|
||||
overallPass,
|
||||
photos: tempPhotos
|
||||
};
|
||||
|
||||
onComplete?.(completedData);
|
||||
}, [inspectionData, calculateOverallScore, tempPhotos, onComplete]);
|
||||
|
||||
const currentCriteria = criteria[currentCriteriaIndex];
|
||||
const currentResult = currentCriteria ? getCriteriaResult(currentCriteria.id) : undefined;
|
||||
const overallScore = calculateOverallScore();
|
||||
const isComplete = isInspectionComplete();
|
||||
|
||||
const renderCriteriaInput = (criterion: InspectionCriteria) => {
|
||||
const result = getCriteriaResult(criterion.id);
|
||||
|
||||
if (criterion.type === 'measurement') {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<Input
|
||||
type="number"
|
||||
placeholder={`Enter ${criterion.name.toLowerCase()}`}
|
||||
value={result?.value as string || ''}
|
||||
onChange={(e) => {
|
||||
const value = parseFloat(e.target.value);
|
||||
const score = !isNaN(value) ? Math.min(10, Math.max(1, 8)) : 0; // Simplified scoring
|
||||
updateResult(criterion.id, {
|
||||
value: e.target.value,
|
||||
score,
|
||||
pass: score >= 7
|
||||
});
|
||||
}}
|
||||
/>
|
||||
{criterion.unit && (
|
||||
<p className="text-sm text-[var(--text-secondary)]">
|
||||
Unit: {criterion.unit}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// For visual, texture, taste types - use 1-10 scale
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-5 gap-2">
|
||||
{[...Array(10)].map((_, i) => {
|
||||
const score = i + 1;
|
||||
const isSelected = result?.score === score;
|
||||
return (
|
||||
<button
|
||||
key={score}
|
||||
className={`p-3 rounded-lg border-2 transition-all ${
|
||||
isSelected
|
||||
? 'border-[var(--color-primary)] bg-[var(--color-primary)]/10'
|
||||
: 'border-[var(--border-primary)] hover:border-[var(--color-primary)]/50'
|
||||
}`}
|
||||
onClick={() => {
|
||||
updateResult(criterion.id, {
|
||||
value: score,
|
||||
score,
|
||||
pass: score >= 7
|
||||
});
|
||||
}}
|
||||
>
|
||||
<div className="text-center">
|
||||
<div className="font-bold text-[var(--text-primary)]">{score}</div>
|
||||
<div className="flex justify-center">
|
||||
<Star className={`w-4 h-4 ${isSelected ? 'text-[var(--color-primary)]' : 'text-[var(--text-tertiary)]'}`} />
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="text-sm text-[var(--text-secondary)]">
|
||||
<p>1-3: Poor | 4-6: Fair | 7-8: Good | 9-10: Excellent</p>
|
||||
<p className="font-medium mt-1">Acceptable: {criterion.acceptableCriteria}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className={className}>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-[var(--text-primary)]">
|
||||
{t('quality.inspection.title', 'Quality Inspection')}
|
||||
</h3>
|
||||
<p className="text-sm text-[var(--text-secondary)]">
|
||||
{productName} " {batchId} " {inspectionType}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Badge variant={isComplete ? 'success' : 'warning'}>
|
||||
{isComplete ? t('quality.status.complete', 'Complete') : t('quality.status.in_progress', 'In Progress')}
|
||||
</Badge>
|
||||
{overallScore > 0 && (
|
||||
<Badge variant={overallScore >= 7 ? 'success' : overallScore >= 5 ? 'warning' : 'error'}>
|
||||
{overallScore.toFixed(1)}/10
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardBody className="space-y-6">
|
||||
<Tabs value={activeTab} onValueChange={setActiveTab}>
|
||||
<TabsList className="grid w-full grid-cols-3">
|
||||
<TabsTrigger value="inspection">
|
||||
{t('quality.tabs.inspection', 'Inspection')}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="photos">
|
||||
{t('quality.tabs.photos', 'Photos')}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="summary">
|
||||
{t('quality.tabs.summary', 'Summary')}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="inspection" className="space-y-6">
|
||||
{/* Progress Indicator */}
|
||||
<div className="grid grid-cols-6 gap-2">
|
||||
{criteria.map((criterion, index) => {
|
||||
const result = getCriteriaResult(criterion.id);
|
||||
const isCompleted = !!result;
|
||||
const isCurrent = index === currentCriteriaIndex;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={criterion.id}
|
||||
className={`p-2 rounded text-xs transition-all ${
|
||||
isCurrent
|
||||
? 'bg-[var(--color-primary)] text-white'
|
||||
: isCompleted
|
||||
? 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200'
|
||||
: 'bg-[var(--bg-secondary)] text-[var(--text-secondary)]'
|
||||
}`}
|
||||
onClick={() => setCurrentCriteriaIndex(index)}
|
||||
>
|
||||
{index + 1}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Current Criteria */}
|
||||
{currentCriteria && (
|
||||
<div className="space-y-6">
|
||||
<div className="bg-[var(--bg-secondary)] p-4 rounded-lg">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<h4 className="font-semibold text-[var(--text-primary)]">
|
||||
{currentCriteria.name}
|
||||
</h4>
|
||||
<Badge variant={currentCriteria.required ? 'error' : 'default'}>
|
||||
{currentCriteria.required ? t('quality.required', 'Required') : t('quality.optional', 'Optional')}
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="text-sm text-[var(--text-secondary)] mb-3">
|
||||
{currentCriteria.description}
|
||||
</p>
|
||||
<div className="flex items-center space-x-4 text-xs text-[var(--text-tertiary)]">
|
||||
<span>Weight: {currentCriteria.weight}%</span>
|
||||
<span>Category: {currentCriteria.category}</span>
|
||||
<span>Type: {currentCriteria.type}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Input Section */}
|
||||
{renderCriteriaInput(currentCriteria)}
|
||||
|
||||
{/* Notes Section */}
|
||||
<Textarea
|
||||
placeholder={t('quality.inspection.notes_placeholder', 'Add notes for this criteria (optional)...')}
|
||||
value={currentResult?.notes || ''}
|
||||
onChange={(e) => {
|
||||
updateResult(currentCriteria.id, { notes: e.target.value });
|
||||
}}
|
||||
rows={3}
|
||||
/>
|
||||
|
||||
{/* Navigation */}
|
||||
<div className="flex justify-between">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setCurrentCriteriaIndex(Math.max(0, currentCriteriaIndex - 1))}
|
||||
disabled={currentCriteriaIndex === 0}
|
||||
>
|
||||
{t('common.previous', 'Previous')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={() => setCurrentCriteriaIndex(Math.min(criteria.length - 1, currentCriteriaIndex + 1))}
|
||||
disabled={currentCriteriaIndex === criteria.length - 1}
|
||||
>
|
||||
{t('common.next', 'Next')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="photos" className="space-y-4">
|
||||
<div className="text-center py-8">
|
||||
<Camera className="w-12 h-12 text-[var(--text-tertiary)] mx-auto mb-4" />
|
||||
<p className="text-[var(--text-secondary)] mb-4">
|
||||
{t('quality.photos.description', 'Add photos to document quality issues or evidence')}
|
||||
</p>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setShowPhotoModal(true)}
|
||||
>
|
||||
<Upload className="w-4 h-4 mr-2" />
|
||||
{t('quality.photos.upload', 'Upload Photos')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{tempPhotos.length > 0 && (
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
{tempPhotos.map((photo, index) => (
|
||||
<div key={index} className="relative">
|
||||
<img
|
||||
src={URL.createObjectURL(photo)}
|
||||
alt={`Quality photo ${index + 1}`}
|
||||
className="w-full h-32 object-cover rounded-lg"
|
||||
/>
|
||||
<button
|
||||
className="absolute top-2 right-2 w-6 h-6 bg-red-500 text-white rounded-full flex items-center justify-center"
|
||||
onClick={() => setTempPhotos(prev => prev.filter((_, i) => i !== index))}
|
||||
>
|
||||
<EFBFBD>
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="summary" className="space-y-6">
|
||||
{/* Overall Score */}
|
||||
<div className="text-center py-6 bg-[var(--bg-secondary)] rounded-lg">
|
||||
<div className="text-4xl font-bold text-[var(--text-primary)] mb-2">
|
||||
{overallScore.toFixed(1)}/10
|
||||
</div>
|
||||
<div className="text-lg text-[var(--text-secondary)]">
|
||||
{t('quality.summary.overall_score', 'Overall Quality Score')}
|
||||
</div>
|
||||
<Badge
|
||||
variant={overallScore >= 7 ? 'success' : overallScore >= 5 ? 'warning' : 'error'}
|
||||
className="mt-2"
|
||||
>
|
||||
{overallScore >= 7 ? t('quality.status.passed', 'PASSED') : t('quality.status.failed', 'FAILED')}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{/* Results Summary */}
|
||||
<div className="space-y-3">
|
||||
<h4 className="font-medium text-[var(--text-primary)]">
|
||||
{t('quality.summary.results', 'Inspection Results')}
|
||||
</h4>
|
||||
{criteria.map((criterion) => {
|
||||
const result = getCriteriaResult(criterion.id);
|
||||
if (!result) return null;
|
||||
|
||||
const StatusIcon = result.pass ? CheckCircle : XCircle;
|
||||
return (
|
||||
<div key={criterion.id} className="flex items-center justify-between p-3 bg-[var(--bg-secondary)] rounded">
|
||||
<div className="flex items-center space-x-3">
|
||||
<StatusIcon className={`w-5 h-5 ${result.pass ? 'text-green-500' : 'text-red-500'}`} />
|
||||
<div>
|
||||
<div className="font-medium text-[var(--text-primary)]">{criterion.name}</div>
|
||||
<div className="text-sm text-[var(--text-secondary)]">{criterion.category}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<div className="font-bold text-[var(--text-primary)]">{result.score}/10</div>
|
||||
<div className="text-sm text-[var(--text-secondary)]">
|
||||
Weight: {criterion.weight}%
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Final Notes */}
|
||||
<Textarea
|
||||
placeholder={t('quality.summary.final_notes', 'Add final notes and recommendations...')}
|
||||
value={inspectionData.finalNotes || ''}
|
||||
onChange={(e) => setInspectionData(prev => ({ ...prev, finalNotes: e.target.value }))}
|
||||
rows={4}
|
||||
/>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="flex justify-between pt-6 border-t border-[var(--border-primary)]">
|
||||
<div className="flex space-x-3">
|
||||
<Button variant="outline" onClick={onCancel}>
|
||||
{t('common.cancel', 'Cancel')}
|
||||
</Button>
|
||||
<Button variant="secondary" onClick={() => onSaveDraft?.(inspectionData)}>
|
||||
{t('quality.actions.save_draft', 'Save Draft')}
|
||||
</Button>
|
||||
</div>
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={handleComplete}
|
||||
disabled={!isComplete}
|
||||
>
|
||||
{t('quality.actions.complete_inspection', 'Complete Inspection')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Photo Upload Modal */}
|
||||
<Modal
|
||||
isOpen={showPhotoModal}
|
||||
onClose={() => setShowPhotoModal(false)}
|
||||
title={t('quality.photos.upload_title', 'Upload Quality Photos')}
|
||||
>
|
||||
<div className="p-6">
|
||||
<input
|
||||
type="file"
|
||||
accept="image/*"
|
||||
multiple
|
||||
onChange={handlePhotoUpload}
|
||||
className="w-full p-4 border-2 border-dashed border-[var(--border-primary)] rounded-lg"
|
||||
/>
|
||||
<p className="text-sm text-[var(--text-secondary)] mt-2">
|
||||
{t('quality.photos.upload_help', 'Select multiple images to upload')}
|
||||
</p>
|
||||
<div className="flex justify-end mt-4">
|
||||
<Button onClick={() => setShowPhotoModal(false)}>
|
||||
{t('common.done', 'Done')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
</CardBody>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default QualityInspection;
|
||||
@@ -1,12 +1,8 @@
|
||||
// Production Domain Components
|
||||
export { default as ProductionSchedule } from './ProductionSchedule';
|
||||
export { default as BatchTracker } from './BatchTracker';
|
||||
export { default as QualityDashboard } from './QualityDashboard';
|
||||
export { default as EquipmentManager } from './EquipmentManager';
|
||||
export { CreateProductionBatchModal } from './CreateProductionBatchModal';
|
||||
export { default as ProductionStatusCard } from './ProductionStatusCard';
|
||||
export { default as QualityCheckModal } from './QualityCheckModal';
|
||||
export { default as ProcessStageTracker } from './ProcessStageTracker';
|
||||
export { default as CompactProcessStageTracker } from './CompactProcessStageTracker';
|
||||
export { default as QualityTemplateManager } from './QualityTemplateManager';
|
||||
export { CreateQualityTemplateModal } from './CreateQualityTemplateModal';
|
||||
@@ -14,8 +10,5 @@ export { EditQualityTemplateModal } from './EditQualityTemplateModal';
|
||||
|
||||
// Export component props types
|
||||
export type { ProductionScheduleProps } from './ProductionSchedule';
|
||||
export type { BatchTrackerProps } from './BatchTracker';
|
||||
export type { QualityDashboardProps, QualityMetrics } from './QualityDashboard';
|
||||
export type { EquipmentManagerProps, Equipment } from './EquipmentManager';
|
||||
export type { ProductionStatusCardProps } from './ProductionStatusCard';
|
||||
export type { QualityCheckModalProps } from './QualityCheckModal';
|
||||
@@ -21,7 +21,25 @@
|
||||
"avg_score": "avg score",
|
||||
"top_defects": "top defects",
|
||||
"issues": "issues",
|
||||
"defects": "defects"
|
||||
"defects": "defects",
|
||||
"overall_efficiency": "Overall Efficiency",
|
||||
"vs_target_95": "vs target 95%",
|
||||
"average_cost_per_unit": "Average Cost per Unit",
|
||||
"down_3_vs_last_week": "down 3% vs last week",
|
||||
"active_equipment": "Active Equipment",
|
||||
"one_in_maintenance": "1 in maintenance",
|
||||
"excellent_standards": "excellent standards",
|
||||
"yield_performance_leaderboard": "Yield Performance Leaderboard",
|
||||
"product_yield_rankings_trends": "Product yield rankings and trends",
|
||||
"no_yield_data": "No yield data available",
|
||||
"product_leaderboard": "Product Leaderboard",
|
||||
"batches": "batches",
|
||||
"best": "best",
|
||||
"yield_comparison": "Yield Comparison",
|
||||
"weekly_yield_trend": "Weekly Yield Trend",
|
||||
"average_yield": "Average Yield",
|
||||
"best_yield": "Best Yield",
|
||||
"yield_trend": "Yield Trend"
|
||||
},
|
||||
"status": {
|
||||
"pending": "Pending",
|
||||
@@ -36,7 +54,8 @@
|
||||
"maintenance": "Maintenance",
|
||||
"down": "Down",
|
||||
"passed": "PASSED",
|
||||
"complete": "Complete"
|
||||
"complete": "Complete",
|
||||
"unknown": "Unknown"
|
||||
},
|
||||
"priority": {
|
||||
"low": "Low",
|
||||
@@ -233,7 +252,14 @@
|
||||
"edit": "Edit",
|
||||
"delete": "Delete",
|
||||
"schedule": "Schedule",
|
||||
"optimize": "Optimize"
|
||||
"optimize": "Optimize",
|
||||
"export_report": "Export Report",
|
||||
"optimize_production": "Optimize Production",
|
||||
"request_beta_access": "Request Beta Access",
|
||||
"join_ai_beta": "Join AI Beta",
|
||||
"optimize_yields": "Optimize Yields",
|
||||
"reduce_waste": "Reduce Waste",
|
||||
"investigate_defects": "Investigate Defects"
|
||||
},
|
||||
"tabs": {
|
||||
"schedule": "Schedule",
|
||||
@@ -254,9 +280,400 @@
|
||||
"messages": {
|
||||
"no_batches": "No production batches found",
|
||||
"no_active_batches": "No active production batches. Create the first batch to begin.",
|
||||
"no_active_batches_description": "Create a new production batch to start tracking progress.",
|
||||
"no_batches_planned": "No batches planned for today",
|
||||
"batch_created": "Production batch created successfully",
|
||||
"batch_updated": "Production batch updated successfully",
|
||||
"batch_started": "Production batch started",
|
||||
"batch_completed": "Production batch completed"
|
||||
},
|
||||
"analytics": {
|
||||
"production_analytics": "Production Analytics",
|
||||
"advanced_insights_professionals_enterprises": "Advanced analytics and insights for professionals and enterprises"
|
||||
},
|
||||
"subscription": {
|
||||
"exclusive_professional_enterprise": "Exclusive content for Professional and Enterprise plans",
|
||||
"advanced_production_analytics_description": "Advanced production analytics are available only for Professional and Enterprise users. Upgrade your plan to access all functionalities.",
|
||||
"upgrade_plan": "Upgrade Plan"
|
||||
},
|
||||
"tabs": {
|
||||
"overview": "Overview",
|
||||
"bakery_operations": "Bakery Operations",
|
||||
"cost_efficiency": "Cost & Efficiency",
|
||||
"quality_assurance": "Quality Assurance",
|
||||
"equipment_maintenance": "Equipment & Maintenance",
|
||||
"ai_insights": "AI Insights"
|
||||
},
|
||||
"schedule": {
|
||||
"todays_summary": "Today's Schedule Summary",
|
||||
"shift_hours_batches_staff": "Shift hours, planned batches, and staff count",
|
||||
"shift_hours": "Shift Hours",
|
||||
"staff_count": "Staff Count",
|
||||
"capacity_utilization": "Capacity Utilization",
|
||||
"planned_batches": "Planned Batches",
|
||||
"active": "Active",
|
||||
"inactive": "Inactive",
|
||||
"finalized": "Finalized",
|
||||
"draft": "Draft",
|
||||
"delayed": "delayed"
|
||||
},
|
||||
"tracker": {
|
||||
"live_batch_tracker": "Live Batch Tracker",
|
||||
"current_batches_status_progress": "Current batches with status, progress bar, and ETA",
|
||||
"progress": "Progress",
|
||||
"pending_start": "Pending Start",
|
||||
"issues": "Issues"
|
||||
},
|
||||
"insights": {
|
||||
"on_time_vs_planned": "Batch completion vs planned schedule",
|
||||
"vs_week_avg": "vs week avg",
|
||||
"target": "Target",
|
||||
"progress_to_target": "Progress to Target",
|
||||
"weekly_trend": "Weekly Trend",
|
||||
"performance_insight": "Performance Insight",
|
||||
"on_time_excellent": "Excellent on-time performance! Keep up the great work.",
|
||||
"on_time_good_room_improvement": "Good performance with room for improvement.",
|
||||
"on_time_needs_attention": "On-time completion needs attention. Review scheduling.",
|
||||
"on_time_critical_delays": "Critical delays detected. Immediate action required.",
|
||||
"batches_on_time": "On Time",
|
||||
"batches_delayed": "Delayed",
|
||||
"avg_delay_minutes": "Avg Delay (min)",
|
||||
"resource_allocation_efficiency": "Monitor resource allocation and efficiency",
|
||||
"resource_breakdown": "Resource Breakdown",
|
||||
"hourly_utilization_pattern": "Hourly Utilization Pattern",
|
||||
"peak_hours": "Peak Hours",
|
||||
"off_peak_optimal": "Off-Peak (Optimal)",
|
||||
"hourly_utilization": "Hourly Utilization"
|
||||
},
|
||||
"cost": {
|
||||
"cost_per_unit_analysis": "Cost Per Unit Analysis",
|
||||
"estimated_vs_actual_costs": "Compare estimated vs actual costs by product",
|
||||
"average_cost_per_unit": "Average Cost per Unit",
|
||||
"total_production_cost": "Total Production Cost",
|
||||
"no_cost_data_available": "No cost data available for analysis",
|
||||
"estimated_vs_actual": "Estimated vs Actual",
|
||||
"product_cost_breakdown": "Product Cost Breakdown",
|
||||
"estimated_cost": "Estimated Cost",
|
||||
"actual_cost": "Actual Cost",
|
||||
"estimated": "Estimated",
|
||||
"actual": "Actual",
|
||||
"variance": "Variance",
|
||||
"cost_distribution": "Cost Distribution",
|
||||
"units_produced": "Units produced",
|
||||
"optimization_opportunity": "Cost Optimization Opportunity",
|
||||
"high_variance_detected": "High cost variance detected in some products. Review pricing and processes.",
|
||||
"costs_within_expected_range": "Costs are within expected range. Continue monitoring for optimization opportunities.",
|
||||
"view_breakdown": "View Breakdown",
|
||||
"waste_cost": "Waste Cost"
|
||||
},
|
||||
"quality": {
|
||||
"waste_defect_tracker": "Waste & Defect Tracker",
|
||||
"waste_sources_trends_costs": "Track waste sources, trends, and cost impact",
|
||||
"total_waste": "Total Waste",
|
||||
"total_defects": "Total Defects",
|
||||
"estimated_cost": "Estimated Cost",
|
||||
"no_waste_data": "No waste data available",
|
||||
"top_waste_sources": "Top Waste Sources",
|
||||
"waste_distribution": "Waste Distribution",
|
||||
"weekly_waste_trend": "Weekly Waste Trend",
|
||||
"waste_percentage": "Waste %",
|
||||
"defect_percentage": "Defect %",
|
||||
"incidents": "incidents",
|
||||
"cost": "cost",
|
||||
"severity": {
|
||||
"low": "Low",
|
||||
"medium": "Medium",
|
||||
"high": "High"
|
||||
},
|
||||
"trend": {
|
||||
"up": "Increasing",
|
||||
"down": "Decreasing",
|
||||
"stable": "Stable"
|
||||
},
|
||||
"defects": {
|
||||
"burnt": "Burnt",
|
||||
"underproofed": "Underproofed",
|
||||
"misshapen": "Misshapen",
|
||||
"color_issues": "Color Issues",
|
||||
"texture_problems": "Texture Problems",
|
||||
"temperature_issues": "Temperature Issues",
|
||||
"expired": "Expired"
|
||||
},
|
||||
"status": {
|
||||
"excellent": "Excellent",
|
||||
"good": "Good",
|
||||
"warning": "Needs Attention",
|
||||
"critical": "Critical"
|
||||
},
|
||||
"recommendations": {
|
||||
"high_waste_detected": "High waste levels detected",
|
||||
"check_temperature_timing": "Check oven temperatures and timing settings for optimal results.",
|
||||
"improvement_opportunity": "Waste Reduction Opportunity",
|
||||
"reduce_waste_target": "Focus on reducing waste to target of {target}.",
|
||||
"maintain_quality_standards": "Continue maintaining excellent quality standards.",
|
||||
"documentation": "Quality Documentation",
|
||||
"photo_documentation_helps": "Photo documentation helps identify root causes of defects.",
|
||||
"critical_defects": "Critical Defects Detected",
|
||||
"immediate_action_required": "High-severity defects require immediate attention and corrective action."
|
||||
},
|
||||
"actions": {
|
||||
"reduce_waste": "Reduce Waste",
|
||||
"focus_on": "Focus on reducing",
|
||||
"investigate_increasing_defects": "Investigate increasing defect trends",
|
||||
"review_process_controls": "Review process controls and training"
|
||||
},
|
||||
"recent_quality_scores": "Recent Quality Scores",
|
||||
"daily_average_quality_trends": "Daily average quality score trends",
|
||||
"daily_quality_score": "Daily Quality Score",
|
||||
"score_distribution": "Score Distribution",
|
||||
"excellent": "Excellent (9-10)",
|
||||
"good": "Good (8-9)",
|
||||
"acceptable": "Acceptable (7-8)",
|
||||
"poor": "Poor (6-7)",
|
||||
"failed": "Failed (<6)",
|
||||
"vs_last_week": "vs last week",
|
||||
"no_quality_data": "No quality data available",
|
||||
"weekly_quality_trends": "Weekly Quality Trends",
|
||||
"excellent_scores": "Excellent",
|
||||
"good_scores": "Good",
|
||||
"needs_improvement": "Needs Improvement",
|
||||
"top_defect_types_24h": "Top Defect Types (Last 24h)",
|
||||
"defect_analysis_cost_impact": "Analyze defect types and their cost impact",
|
||||
"no_defects_detected": "No Defects Detected",
|
||||
"excellent_quality_standards": "Maintaining excellent quality standards across all products.",
|
||||
"defect_breakdown": "Defect Breakdown",
|
||||
"defect_distribution": "Defect Distribution",
|
||||
"top_defects_weekly_trend": "Top Defects - Weekly Trend",
|
||||
"recommended_actions": "Recommended Actions"
|
||||
},
|
||||
"performance": {
|
||||
"excellent": "Excellent",
|
||||
"good": "Good",
|
||||
"average": "Average",
|
||||
"poor": "Poor",
|
||||
"needs_improvement": "Needs Improvement"
|
||||
},
|
||||
"recommendations": {
|
||||
"yield_improvement": "Yield Improvement Opportunity",
|
||||
"focus_on_low_performers": "Focus on improving low-performing products to increase overall efficiency.",
|
||||
"maintain_high_standards": "Continue maintaining high yield standards across all products.",
|
||||
"improve_quality": "Quality Improvement Needed",
|
||||
"focus_consistency": "Focus on improving consistency in production processes.",
|
||||
"excellent_quality": "Excellent Quality Performance",
|
||||
"maintain_standards": "Continue maintaining these high standards."
|
||||
},
|
||||
"common": {
|
||||
"units": "units",
|
||||
"assigned": "assigned",
|
||||
"allocated": "allocated",
|
||||
"available": "available"
|
||||
},
|
||||
"alerts": {
|
||||
"capacity_critical": "Capacity Critical",
|
||||
"capacity_critical_description": "Some resources are at maximum capacity. Consider redistributing workload.",
|
||||
"capacity_high": "High Capacity Utilization",
|
||||
"capacity_high_description": "Monitor closely to prevent bottlenecks."
|
||||
},
|
||||
"mobile": {
|
||||
"optimized_experience": "Mobile Optimized Experience",
|
||||
"swipe_scroll_interact": "Swipe, scroll, and tap to interact with all analytics widgets."
|
||||
},
|
||||
"equipment": {
|
||||
"oven_capacity": "Oven Capacity",
|
||||
"mixer_capacity": "Mixer Capacity",
|
||||
"coming_soon": "Coming Soon",
|
||||
"equipment_analytics_development": "Equipment analytics are currently in development. Advanced monitoring and predictive maintenance features will be available soon."
|
||||
},
|
||||
"ai": {
|
||||
"ai_insights_coming_soon": "AI Insights Coming Soon",
|
||||
"ai_powered_recommendations_development": "AI-powered recommendations and predictive analytics are in development. Join the beta program for early access.",
|
||||
"title": "AI Insights",
|
||||
"subtitle": "AI-powered recommendations and predictions",
|
||||
"stats": {
|
||||
"active_insights": "Active Insights",
|
||||
"high_priority": "High Priority",
|
||||
"potential_savings": "Potential Savings",
|
||||
"avg_confidence": "Avg Confidence"
|
||||
},
|
||||
"status": {
|
||||
"active": "AI monitoring active",
|
||||
"new": "New",
|
||||
"acknowledged": "Acknowledged",
|
||||
"in_progress": "In Progress",
|
||||
"implemented": "Implemented",
|
||||
"dismissed": "Dismissed"
|
||||
},
|
||||
"priority": {
|
||||
"low": "Low",
|
||||
"medium": "Medium",
|
||||
"high": "High",
|
||||
"critical": "Critical"
|
||||
},
|
||||
"impact": {
|
||||
"cost_savings": "Cost Savings",
|
||||
"efficiency_gain": "Efficiency Gain",
|
||||
"quality_improvement": "Quality Improvement",
|
||||
"risk_mitigation": "Risk Mitigation"
|
||||
},
|
||||
"actions": {
|
||||
"train_model": "Train Model",
|
||||
"implement_all": "Implement All",
|
||||
"acknowledge": "Acknowledge",
|
||||
"implement": "Implement"
|
||||
},
|
||||
"confidence": "Confidence",
|
||||
"timeline": "Timeline",
|
||||
"equipment": "Equipment",
|
||||
"high_priority_insights": "High Priority Insights",
|
||||
"all_insights": "All Insights",
|
||||
"last_updated": "Updated 5m ago",
|
||||
"performance": {
|
||||
"summary": "AI Performance Summary",
|
||||
"insights_implemented": "insights implemented this month",
|
||||
"in_savings_identified": "in potential savings identified"
|
||||
},
|
||||
"predictive_maintenance": {
|
||||
"title": "Predictive Maintenance",
|
||||
"subtitle": "AI-powered failure prediction and maintenance optimization",
|
||||
"high_risk": "High Risk Equipment",
|
||||
"estimated_cost": "Estimated Cost",
|
||||
"potential_downtime": "Potential Downtime",
|
||||
"avg_confidence": "Avg Confidence",
|
||||
"high_risk_equipment": "High Risk Equipment",
|
||||
"immediate_attention_required": "Immediate attention required to prevent failures",
|
||||
"predictions": "Maintenance Predictions",
|
||||
"confidence": "Confidence",
|
||||
"risk_score": "Risk Score",
|
||||
"days_until_failure": "Days Until Failure",
|
||||
"condition": "Condition",
|
||||
"current_condition": "Current Condition",
|
||||
"affected_components": "Affected Components",
|
||||
"recommended_actions": "Recommended Actions",
|
||||
"schedule": "Schedule",
|
||||
"details": "View Details",
|
||||
"schedule_all": "Schedule All",
|
||||
"retrain_model": "Retrain Model",
|
||||
"condition_trends": "Equipment Condition Trends",
|
||||
"risk_distribution": "Risk Distribution",
|
||||
"model_status": "ML Model Status",
|
||||
"last_training": "Last training",
|
||||
"yesterday": "yesterday",
|
||||
"accuracy": "Accuracy",
|
||||
"next_training": "Next training",
|
||||
"in_7_days": "in 7 days",
|
||||
"alert_type": {
|
||||
"wear_prediction": "Wear pattern indicates potential failure",
|
||||
"failure_risk": "High failure risk detected",
|
||||
"performance_degradation": "Performance degradation detected",
|
||||
"component_replacement": "Component replacement recommended"
|
||||
}
|
||||
},
|
||||
"severity": {
|
||||
"low": "Low",
|
||||
"medium": "Medium",
|
||||
"high": "High",
|
||||
"critical": "Critical"
|
||||
},
|
||||
"risk": {
|
||||
"low": "Low Risk",
|
||||
"medium": "Medium Risk",
|
||||
"high": "High Risk"
|
||||
}
|
||||
},
|
||||
"equipment": {
|
||||
"title": "Equipment Status",
|
||||
"subtitle": "Monitor equipment health and performance",
|
||||
"status": {
|
||||
"operational": "Operational",
|
||||
"warning": "Warning",
|
||||
"maintenance": "Maintenance",
|
||||
"down": "Down",
|
||||
"equipment_list": "Equipment List",
|
||||
"distribution": "Status Distribution"
|
||||
},
|
||||
"stats": {
|
||||
"total": "Total Equipment",
|
||||
"operational": "Operational",
|
||||
"needs_attention": "Needs Attention",
|
||||
"efficiency": "Efficiency",
|
||||
"avg_efficiency": "Avg Efficiency",
|
||||
"alerts": "Active Alerts",
|
||||
"by_equipment": "by Equipment"
|
||||
},
|
||||
"efficiency": {
|
||||
"title": "Equipment Efficiency",
|
||||
"subtitle": "Monitor performance and optimization opportunities",
|
||||
"average": "Average Efficiency",
|
||||
"current": "Current",
|
||||
"target": "Target",
|
||||
"current_vs_target": "Current vs Target Efficiency",
|
||||
"weekly_trends": "Weekly Efficiency Trends",
|
||||
"by_equipment": "Efficiency by Equipment",
|
||||
"analyze": "Analyze",
|
||||
"optimize": "Optimize",
|
||||
"recommendations": "Efficiency Recommendations",
|
||||
"recommendation_1": "Priority Action",
|
||||
"recommendation_2": "Energy Optimization",
|
||||
"recommendation_3": "Preventive Care",
|
||||
"needs_maintenance": "requires maintenance attention",
|
||||
"optimize_energy_consumption": "Optimize energy consumption during peak hours",
|
||||
"schedule_preventive_maintenance": "Schedule preventive maintenance for all equipment"
|
||||
},
|
||||
"efficiency": "Efficiency",
|
||||
"uptime": "Uptime",
|
||||
"temperature": "Temperature",
|
||||
"energy_consumption": "Energy Consumption",
|
||||
"unread_alerts": "unread alerts",
|
||||
"units_per_hour": "units/hour",
|
||||
"downtime": {
|
||||
"total": "Total Downtime"
|
||||
},
|
||||
"oee": {
|
||||
"overall": "Overall OEE",
|
||||
"breakdown": "OEE Breakdown",
|
||||
"availability": "Availability",
|
||||
"performance": "Performance",
|
||||
"quality": "Quality"
|
||||
},
|
||||
"actions": {
|
||||
"schedule_maintenance": "Schedule Maintenance",
|
||||
"add_task": "Add Task"
|
||||
},
|
||||
"maintenance": {
|
||||
"title": "Maintenance Schedule",
|
||||
"subtitle": "Plan and track maintenance activities",
|
||||
"scheduled": "Scheduled",
|
||||
"overdue": "Overdue",
|
||||
"avg_duration": "Avg Duration",
|
||||
"total_cost": "Total Cost",
|
||||
"overdue_tasks": "Overdue Tasks",
|
||||
"immediate_attention_required": "Immediate attention required",
|
||||
"tasks": "Maintenance Tasks",
|
||||
"duration": "Duration",
|
||||
"cost": "Cost",
|
||||
"technician": "Technician",
|
||||
"this_week": "This Week's Schedule",
|
||||
"insights": {
|
||||
"title": "Maintenance Insights",
|
||||
"completed_this_month": "tasks completed this month",
|
||||
"scheduled_next_week": "tasks scheduled for next week"
|
||||
},
|
||||
"status": {
|
||||
"scheduled": "Scheduled",
|
||||
"in_progress": "In Progress",
|
||||
"completed": "Completed",
|
||||
"overdue": "Overdue"
|
||||
},
|
||||
"type": {
|
||||
"preventive": "Preventive",
|
||||
"corrective": "Corrective",
|
||||
"inspection": "Inspection"
|
||||
}
|
||||
},
|
||||
"alerts": {
|
||||
"maintenance_required": "Maintenance Required",
|
||||
"equipment_in_maintenance": "equipment in maintenance",
|
||||
"active_alerts": "active alerts"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,84 +1,676 @@
|
||||
{
|
||||
"title": "Producción",
|
||||
"subtitle": "Gestiona la producción de tu panadería",
|
||||
"production_status": {
|
||||
"PENDING": "Pendiente",
|
||||
"IN_PROGRESS": "En Proceso",
|
||||
"COMPLETED": "Completado",
|
||||
"CANCELLED": "Cancelado",
|
||||
"ON_HOLD": "En Pausa",
|
||||
"QUALITY_CHECK": "Control Calidad",
|
||||
"FAILED": "Fallido"
|
||||
"title": "Gestión de Producción",
|
||||
"subtitle": "Planifica y controla la producción diaria de la panadería",
|
||||
"stats": {
|
||||
"active_batches": "Lotes Activos",
|
||||
"todays_target": "Objetivo de Hoy",
|
||||
"capacity_utilization": "Utilización de Capacidad",
|
||||
"on_time_completion": "Finalización a Tiempo",
|
||||
"quality_score": "Puntuación de Calidad",
|
||||
"total_output": "Producción Total",
|
||||
"efficiency": "Eficiencia",
|
||||
"total_checks": "Controles Totales",
|
||||
"pass_rate": "Tasa de Aprobación",
|
||||
"average_score": "Puntuación Promedio",
|
||||
"failed_checks": "Controles Fallidos",
|
||||
"last_7_days": "Últimos 7 días",
|
||||
"passed": "aprobados",
|
||||
"out_of_10": "de 10",
|
||||
"requiring_action": "Requieren acción",
|
||||
"checks": "controles",
|
||||
"avg_score": "puntuación media",
|
||||
"top_defects": "principales defectos",
|
||||
"issues": "problemas",
|
||||
"defects": "defectos",
|
||||
"overall_efficiency": "Eficiencia General",
|
||||
"vs_target_95": "vs objetivo 95%",
|
||||
"average_cost_per_unit": "Costo Promedio por Unidad",
|
||||
"down_3_vs_last_week": "−3% vs semana pasada",
|
||||
"active_equipment": "Equipos Activos",
|
||||
"one_in_maintenance": "1 en mantenimiento",
|
||||
"excellent_standards": "Estándares excelentes",
|
||||
"planned_batches": "Lotes Planificados",
|
||||
"batches": "lotes",
|
||||
"best": "mejor",
|
||||
"no_yield_data": "No hay datos de rendimiento disponibles",
|
||||
"product_leaderboard": "Tabla de Clasificación de Productos",
|
||||
"yield_comparison": "Comparación de Rendimiento",
|
||||
"weekly_yield_trend": "Tendencia Semanal de Rendimiento",
|
||||
"product_yield_rankings_trends": "Clasificación de rendimiento de productos y tendencias",
|
||||
"average_yield": "Rendimiento Promedio",
|
||||
"best_yield": "Mejor Rendimiento",
|
||||
"yield_trend": "Tendencia de Rendimiento"
|
||||
},
|
||||
"production_priority": {
|
||||
"LOW": "Baja",
|
||||
"MEDIUM": "Media",
|
||||
"HIGH": "Alta",
|
||||
"URGENT": "Urgente"
|
||||
"status": {
|
||||
"pending": "Pendiente",
|
||||
"in_progress": "En Proceso",
|
||||
"completed": "Completado",
|
||||
"cancelled": "Cancelado",
|
||||
"on_hold": "En Pausa",
|
||||
"quality_check": "Control de Calidad",
|
||||
"failed": "Fallido",
|
||||
"operational": "Operativo",
|
||||
"warning": "Advertencia",
|
||||
"maintenance": "Mantenimiento",
|
||||
"down": "Fuera de Servicio",
|
||||
"passed": "APROBADO",
|
||||
"complete": "Completo",
|
||||
"unknown": "Desconocido",
|
||||
"excellent": "Excelente",
|
||||
"good": "Bueno",
|
||||
"warning": "Necesita Atención",
|
||||
"critical": "Crítico"
|
||||
},
|
||||
"batch_status": {
|
||||
"PLANNED": "Planificado",
|
||||
"IN_PROGRESS": "En Proceso",
|
||||
"COMPLETED": "Completado",
|
||||
"CANCELLED": "Cancelado",
|
||||
"ON_HOLD": "En Pausa"
|
||||
"priority": {
|
||||
"low": "Baja",
|
||||
"medium": "Media",
|
||||
"high": "Alta",
|
||||
"urgent": "Urgente"
|
||||
},
|
||||
"quality_check_status": {
|
||||
"PENDING": "Pendiente",
|
||||
"IN_PROGRESS": "En Proceso",
|
||||
"PASSED": "Aprobado",
|
||||
"FAILED": "Reprobado",
|
||||
"REQUIRES_ATTENTION": "Requiere Atención"
|
||||
},
|
||||
"fields": {
|
||||
"batch": {
|
||||
"title": "Lote de Producción",
|
||||
"batch_number": "Número de Lote",
|
||||
"production_date": "Fecha de Producción",
|
||||
"product_name": "Nombre del Producto",
|
||||
"planned_quantity": "Cantidad Planificada",
|
||||
"actual_quantity": "Cantidad Real",
|
||||
"yield_percentage": "Porcentaje de Rendimiento",
|
||||
"priority": "Prioridad",
|
||||
"assigned_staff": "Personal Asignado",
|
||||
"production_notes": "Notas de Producción",
|
||||
"quality_score": "Puntuación de Calidad",
|
||||
"quality_notes": "Notas de Calidad",
|
||||
"defect_rate": "Tasa de Defectos",
|
||||
"rework_required": "Requiere Retrabajo",
|
||||
"waste_quantity": "Cantidad de Desperdicio",
|
||||
"waste_reason": "Razón del Desperdicio",
|
||||
"efficiency": "Eficiencia",
|
||||
"material_cost": "Costo de Materiales",
|
||||
"labor_cost": "Costo de Mano de Obra",
|
||||
"overhead_cost": "Costo Indirecto",
|
||||
"planned_start": "Inicio Planificado",
|
||||
"actual_start": "Inicio Real",
|
||||
"planned_end": "Fin Planificado",
|
||||
"actual_end": "Fin Real",
|
||||
"staff_assigned": "Personal Asignado",
|
||||
"equipment_used": "Equipo Utilizado",
|
||||
"notes": "Notas",
|
||||
"yield_percentage": "Rendimiento",
|
||||
"duration": "Duración",
|
||||
"cost": "Costo",
|
||||
"rush_order": "Pedido Urgente",
|
||||
"actual": "Real",
|
||||
"staff": "Personal",
|
||||
"equipment": "Equipo"
|
||||
},
|
||||
"quality": {
|
||||
"title": "Control de Calidad",
|
||||
"dashboard": {
|
||||
"title": "Panel de Calidad",
|
||||
"subtitle": "Monitorear métricas y tendencias de calidad",
|
||||
"error": "Error al cargar datos de calidad"
|
||||
},
|
||||
"inspection": {
|
||||
"title": "Inspección de Calidad",
|
||||
"notes_placeholder": "Agregar notas para este criterio (opcional)..."
|
||||
},
|
||||
"tabs": {
|
||||
"overview": "Resumen",
|
||||
"by_product": "Por Producto",
|
||||
"categories": "Categorías",
|
||||
"recent": "Reciente",
|
||||
"inspection": "Inspección",
|
||||
"photos": "Fotos",
|
||||
"summary": "Resumen"
|
||||
},
|
||||
"charts": {
|
||||
"weekly_trends": "Tendencias Semanales de Calidad",
|
||||
"placeholder": "El gráfico de tendencias de calidad se mostrará aquí"
|
||||
},
|
||||
"photos": {
|
||||
"description": "Agregar fotos para documentar problemas de calidad o evidencia",
|
||||
"upload": "Subir Fotos",
|
||||
"upload_title": "Subir Fotos de Calidad",
|
||||
"upload_help": "Seleccionar múltiples imágenes para subir"
|
||||
},
|
||||
"summary": {
|
||||
"overall_score": "Puntuación General de Calidad",
|
||||
"results": "Resultados de Inspección",
|
||||
"final_notes": "Agregar notas finales y recomendaciones..."
|
||||
},
|
||||
"actions": {
|
||||
"new_check": "Nuevo Control",
|
||||
"view_trends": "Ver Tendencias",
|
||||
"save_draft": "Guardar Borrador",
|
||||
"complete_inspection": "Completar Inspección",
|
||||
"check_ingredients": "Verificar Ingredientes",
|
||||
"adjust_recipe": "Ajustar Receta",
|
||||
"view_details": "Ver Detalles",
|
||||
"reduce_waste": "Reducir Desperdicios",
|
||||
"focus_on": "Enfocarse en reducir",
|
||||
"investigate_increasing_defects": "Investigar tendencias de defectos crecientes",
|
||||
"review_process_controls": "Revisar controles de proceso y capacitación"
|
||||
},
|
||||
"required": "Obligatorio",
|
||||
"optional": "Opcional",
|
||||
"trends": {
|
||||
"vs_last_week": "vs semana pasada"
|
||||
},
|
||||
"waste_defect_tracker": "Rastreador de Desperdicios y Defectos",
|
||||
"waste_sources_trends_costs": "Rastrear fuentes de desperdicios, tendencias e impacto de costos",
|
||||
"total_waste": "Desperdicio Total",
|
||||
"total_defects": "Defectos Totales",
|
||||
"estimated_cost": "Costo Estimado",
|
||||
"no_waste_data": "No hay datos de desperdicios disponibles",
|
||||
"top_waste_sources": "Principales Fuentes de Desperdicios",
|
||||
"waste_distribution": "Distribución de Desperdicios",
|
||||
"weekly_waste_trend": "Tendencia Semanal de Desperdicios",
|
||||
"waste_percentage": "% Desperdicios",
|
||||
"defect_percentage": "% Defectos",
|
||||
"incidents": "incidentes",
|
||||
"cost": "costo",
|
||||
"severity": {
|
||||
"low": "Bajo",
|
||||
"medium": "Medio",
|
||||
"high": "Alto"
|
||||
},
|
||||
"trend": {
|
||||
"up": "Aumentando",
|
||||
"down": "Disminuyendo",
|
||||
"stable": "Estable"
|
||||
},
|
||||
"defects": {
|
||||
"burnt": "Quemado",
|
||||
"underproofed": "Poco Fermentado",
|
||||
"misshapen": "Deformado",
|
||||
"color_issues": "Problemas de Color",
|
||||
"texture_problems": "Problemas de Textura",
|
||||
"temperature_issues": "Problemas de Temperatura",
|
||||
"expired": "Caducado"
|
||||
},
|
||||
"recommendations": {
|
||||
"high_waste_detected": "Niveles altos de desperdicio detectados",
|
||||
"check_temperature_timing": "Verificar temperaturas del horno y configuraciones de tiempo para resultados óptimos.",
|
||||
"improvement_opportunity": "Oportunidad de Reducción de Desperdicios",
|
||||
"reduce_waste_target": "Enfocarse en reducir desperdicios al objetivo de {target}.",
|
||||
"maintain_quality_standards": "Continuar manteniendo estándares de calidad excelentes.",
|
||||
"documentation": "Documentación de Calidad",
|
||||
"photo_documentation_helps": "La documentación fotográfica ayuda a identificar las causas raíz de los defectos.",
|
||||
"critical_defects": "Defectos Críticos Detectados",
|
||||
"immediate_action_required": "Los defectos de alta gravedad requieren atención inmediata y acción correctiva.",
|
||||
"improve_quality": "Mejora de Calidad Necesaria",
|
||||
"focus_consistency": "Enfocarse en mejorar la consistencia en los procesos de producción.",
|
||||
"excellent_quality": "Excelente Rendimiento de Calidad",
|
||||
"maintain_standards": "Continuar manteniendo estos altos estándares."
|
||||
},
|
||||
"recent_quality_scores": "Puntuaciones de Calidad Recientes",
|
||||
"daily_average_quality_trends": "Tendencias diarias de puntuación promedio de calidad",
|
||||
"daily_quality_score": "Puntuación Diaria de Calidad",
|
||||
"score_distribution": "Distribución de Puntuaciones",
|
||||
"excellent": "Excelente (9-10)",
|
||||
"good": "Bueno (8-9)",
|
||||
"acceptable": "Aceptable (7-8)",
|
||||
"poor": "Pobre (6-7)",
|
||||
"failed": "Fallido (<6)",
|
||||
"vs_last_week": "vs semana pasada",
|
||||
"no_quality_data": "No hay datos de calidad disponibles",
|
||||
"weekly_quality_trends": "Tendencias Semanales de Calidad",
|
||||
"excellent_scores": "Excelente",
|
||||
"good_scores": "Bueno",
|
||||
"needs_improvement": "Necesita Mejora",
|
||||
"top_defect_types_24h": "Principales Tipos de Defectos (Últimas 24h)",
|
||||
"defect_analysis_cost_impact": "Analizar tipos de defectos y su impacto en costos",
|
||||
"no_defects_detected": "No se Detectaron Defectos",
|
||||
"excellent_quality_standards": "Manteniendo estándares de calidad excelentes en todos los productos.",
|
||||
"defect_breakdown": "Desglose de Defectos",
|
||||
"defect_distribution": "Distribución de Defectos",
|
||||
"top_defects_weekly_trend": "Principales Defectos - Tendencia Semanal",
|
||||
"recommended_actions": "Acciones Recomendadas"
|
||||
},
|
||||
"cost": {
|
||||
"title": "Monitor de Costos de Producción",
|
||||
"subtitle": "Rastrear y optimizar costos de producción",
|
||||
"total_cost": "Costo Total",
|
||||
"cost_per_unit": "Costo por Unidad"
|
||||
},
|
||||
"actions": {
|
||||
"start_production": "Iniciar Producción",
|
||||
"complete_batch": "Completar Lote",
|
||||
"pause_production": "Pausar Producción",
|
||||
"cancel_batch": "Cancelar Lote",
|
||||
"quality_check": "Control de Calidad",
|
||||
"create_batch": "Crear Lote",
|
||||
"cost_per_unit": "Costo por Unidad",
|
||||
"labor": "Mano de Obra",
|
||||
"materials": "Materiales",
|
||||
"overhead": "Gastos Generales",
|
||||
"energy": "Energía",
|
||||
"breakdown": "Desglose de Costos",
|
||||
"budget_usage": "Uso del Presupuesto",
|
||||
"over_budget": "Sobre Presupuesto",
|
||||
"near_budget": "Cerca del Presupuesto",
|
||||
"on_budget": "Dentro del Presupuesto",
|
||||
"vs_yesterday": "vs ayer",
|
||||
"average_today": "Promedio hoy",
|
||||
"view_details": "Ver Detalles",
|
||||
"edit_batch": "Editar Lote",
|
||||
"duplicate_batch": "Duplicar Lote"
|
||||
},
|
||||
"labels": {
|
||||
"current_production": "Producción Actual",
|
||||
"production_queue": "Cola de Producción",
|
||||
"completed_today": "Completado Hoy",
|
||||
"efficiency_rate": "Tasa de Eficiencia",
|
||||
"quality_score": "Puntuación de Calidad",
|
||||
"active_batches": "Lotes Activos",
|
||||
"pending_quality_checks": "Controles de Calidad Pendientes"
|
||||
},
|
||||
"descriptions": {
|
||||
"production_efficiency": "Porcentaje de eficiencia en la producción actual",
|
||||
"quality_average": "Puntuación promedio de calidad en los últimos lotes",
|
||||
"waste_reduction": "Reducción de desperdicio comparado con el mes anterior"
|
||||
"optimize": "Optimizar Costos",
|
||||
"error": "Error al cargar datos de costos",
|
||||
"cost_per_unit_analysis": "Análisis de Costo por Unidad",
|
||||
"estimated_vs_actual_costs": "Comparar costos estimados vs reales por producto",
|
||||
"average_cost_per_unit": "Costo Promedio por Unidad",
|
||||
"total_production_cost": "Costo Total de Producción",
|
||||
"no_cost_data_available": "No hay datos de costos disponibles para análisis",
|
||||
"estimated_vs_actual": "Estimado vs Real",
|
||||
"product_cost_breakdown": "Desglose de Costos por Producto",
|
||||
"estimated_cost": "Costo Estimado",
|
||||
"actual_cost": "Costo Real",
|
||||
"estimated": "Estimado",
|
||||
"actual": "Real",
|
||||
"variance": "Varianza",
|
||||
"cost_distribution": "Distribución de Costos",
|
||||
"units_produced": "Unidades producidas",
|
||||
"optimization_opportunity": "Oportunidad de Optimización de Costos",
|
||||
"high_variance_detected": "Alta varianza de costos detectada en algunos productos. Revisar precios y procesos.",
|
||||
"costs_within_expected_range": "Los costos están dentro del rango esperado. Continuar monitoreando para oportunidades de optimización.",
|
||||
"view_breakdown": "Ver Desglose",
|
||||
"waste_cost": "Costo de Desperdicios"
|
||||
},
|
||||
"equipment": {
|
||||
"title": "Equipos",
|
||||
"subtitle": "Gestión de maquinaria de producción"
|
||||
"title": "Estado del Equipo",
|
||||
"subtitle": "Monitorear salud y rendimiento del equipo",
|
||||
"manager": {
|
||||
"title": "Gestión de Equipos",
|
||||
"subtitle": "Monitorear y gestionar equipos de producción"
|
||||
},
|
||||
"status": {
|
||||
"operational": "Operativo",
|
||||
"warning": "Advertencia",
|
||||
"maintenance": "Mantenimiento",
|
||||
"down": "Fuera de Servicio"
|
||||
},
|
||||
"stats": {
|
||||
"total": "Total de Equipos",
|
||||
"operational": "Operativo",
|
||||
"needs_attention": "Necesita Atención",
|
||||
"efficiency": "Eficiencia",
|
||||
"avg_efficiency": "Eficiencia Promedio",
|
||||
"alerts": "Alertas Activas"
|
||||
},
|
||||
"filter": {
|
||||
"all": "Todos los Estados"
|
||||
},
|
||||
"tabs": {
|
||||
"overview": "Resumen",
|
||||
"maintenance": "Mantenimiento",
|
||||
"alerts": "Alertas"
|
||||
},
|
||||
"maintenance": {
|
||||
"overdue": "Vencido",
|
||||
"scheduled": "Programado",
|
||||
"last": "Último",
|
||||
"next": "Próximo",
|
||||
"interval": "Intervalo",
|
||||
"history": "Historial",
|
||||
"records": "registros"
|
||||
},
|
||||
"alerts": {
|
||||
"acknowledged": "Reconocidas",
|
||||
"new": "Nuevas",
|
||||
"acknowledge": "Reconocer"
|
||||
},
|
||||
"search": {
|
||||
"placeholder": "Buscar equipos..."
|
||||
},
|
||||
"actions": {
|
||||
"add": "Agregar Equipo",
|
||||
"export": "Exportar",
|
||||
"maintenance": "Mantenimiento",
|
||||
"schedule_maintenance": "Programar Mantenimiento",
|
||||
"view_history": "Ver Historial"
|
||||
},
|
||||
"efficiency": "Eficiencia",
|
||||
"uptime": "Tiempo de Actividad",
|
||||
"location": "Ubicación",
|
||||
"temperature": "Temperatura",
|
||||
"energy_usage": "Uso de Energía",
|
||||
"unread_alerts": "alertas no leídas",
|
||||
"model": "Modelo",
|
||||
"serial": "Número de Serie",
|
||||
"install_date": "Fecha de Instalación",
|
||||
"error": "Error al cargar datos del equipo",
|
||||
"oven_capacity": "Capacidad del Horno",
|
||||
"mixer_capacity": "Capacidad de la Batidora",
|
||||
"coming_soon": "Próximamente",
|
||||
"equipment_analytics_development": "Los análisis de equipos están actualmente en desarrollo. Las funciones avanzadas de monitoreo y mantenimiento predictivo estarán disponibles pronto."
|
||||
},
|
||||
"ai": {
|
||||
"title": "Perspectivas de IA",
|
||||
"subtitle": "Recomendaciones y predicciones impulsadas por IA",
|
||||
"high_impact": "Alto Impacto",
|
||||
"potential_savings": "Ahorros Potenciales",
|
||||
"confidence": "Confianza Promedio",
|
||||
"impact": "IMPACTO",
|
||||
"insights": {
|
||||
"reduce_energy_costs": "Reducir Costos de Energía en 15%",
|
||||
"energy_description": "Ajustar horarios del horno para usar tarifas eléctricas de menor costo. Ahorro estimado: €45/día",
|
||||
"demand_increase": "Aumento de Demanda de Croissants Predicho",
|
||||
"demand_description": "Los datos de clima y eventos sugieren un aumento del 40% en la demanda de croissants este fin de semana",
|
||||
"quality_decline": "Declive en Puntuación de Calidad Detectado",
|
||||
"quality_description": "La calidad del pan ha disminuido un 8% en los últimos 3 días. Verificar niveles de humedad de la harina",
|
||||
"maintenance_due": "Mantenimiento Preventivo Recomendado",
|
||||
"maintenance_description": "La batidora #2 muestra patrones de desgaste temprano. Programar mantenimiento para prevenir averías"
|
||||
},
|
||||
"actions": {
|
||||
"schedule_optimization": "Optimizar Horario",
|
||||
"view_details": "Ver Detalles",
|
||||
"increase_production": "Aumentar Producción",
|
||||
"order_ingredients": "Ordenar Ingredientes",
|
||||
"schedule_maintenance": "Programar Mantenimiento",
|
||||
"view_equipment": "Ver Equipo"
|
||||
},
|
||||
"status": {
|
||||
"active": "Monitoreo de IA activo"
|
||||
},
|
||||
"last_updated": "Actualizado hace 5m",
|
||||
"error": "Perspectivas de IA temporalmente no disponibles",
|
||||
"ai_insights_coming_soon": "Perspectivas de IA Próximamente",
|
||||
"ai_powered_recommendations_development": "Las recomendaciones impulsadas por IA y los análisis predictivos están en desarrollo. Únete al programa beta para acceso temprano."
|
||||
},
|
||||
"actions": {
|
||||
"create_batch": "Crear Lote",
|
||||
"start_batch": "Iniciar Lote",
|
||||
"complete_batch": "Completar Lote",
|
||||
"view_details": "Ver Detalles",
|
||||
"edit": "Editar",
|
||||
"delete": "Eliminar",
|
||||
"schedule": "Programar",
|
||||
"optimize": "Optimizar",
|
||||
"export_report": "Exportar Reporte",
|
||||
"optimize_production": "Optimizar Producción",
|
||||
"analyze_delays": "Analizar Retrasos",
|
||||
"optimize_yields": "Optimizar Rendimientos",
|
||||
"refresh": "Actualizar",
|
||||
"request_beta_access": "Solicitar Acceso Beta",
|
||||
"join_ai_beta": "Unirse al Beta de IA",
|
||||
"reduce_waste": "Reducir Desperdicios",
|
||||
"investigate_defects": "Investigar Defectos"
|
||||
},
|
||||
"tabs": {
|
||||
"schedule": "Horario",
|
||||
"batches": "Lotes de Producción",
|
||||
"quality": "Control de Calidad",
|
||||
"overview": "Resumen",
|
||||
"bakery_operations": "Operaciones de Panadería",
|
||||
"cost_efficiency": "Costo y Eficiencia",
|
||||
"quality_assurance": "Aseguramiento de Calidad",
|
||||
"equipment_maintenance": "Equipo y Mantenimiento",
|
||||
"ai_insights": "Perspectivas de IA"
|
||||
},
|
||||
"forms": {
|
||||
"batch_number": "Número de Lote",
|
||||
"product_selection": "Selección de Producto",
|
||||
"quantity": "Cantidad",
|
||||
"start_time": "Hora de Inicio",
|
||||
"end_time": "Hora de Fin",
|
||||
"priority": "Prioridad",
|
||||
"staff": "Personal Asignado",
|
||||
"equipment": "Equipo",
|
||||
"notes": "Notas de Producción"
|
||||
},
|
||||
"messages": {
|
||||
"no_batches": "No se encontraron lotes de producción",
|
||||
"no_active_batches": "No hay lotes de producción activos. Crea el primer lote para comenzar.",
|
||||
"no_active_batches_description": "Crea un nuevo lote de producción para comenzar a rastrear el progreso.",
|
||||
"no_batches_planned": "No hay lotes planificados para hoy",
|
||||
"batch_created": "Lote de producción creado exitosamente",
|
||||
"batch_updated": "Lote de producción actualizado exitosamente",
|
||||
"batch_started": "Lote de producción iniciado",
|
||||
"batch_completed": "Lote de producción completado"
|
||||
},
|
||||
"analytics": {
|
||||
"production_analytics": "Análisis de Producción",
|
||||
"advanced_insights_professionals_enterprises": "Análisis avanzado y perspectivas para profesionales y empresas"
|
||||
},
|
||||
"subscription": {
|
||||
"exclusive_professional_enterprise": "Contenido exclusivo para planes Professional y Enterprise",
|
||||
"advanced_production_analytics_description": "Los análisis avanzados de producción están disponibles solo para usuarios Professional y Enterprise. Actualiza tu plan para acceder a todas las funcionalidades.",
|
||||
"upgrade_plan": "Actualizar Plan"
|
||||
},
|
||||
"schedule": {
|
||||
"todays_summary": "Resumen de Hoy",
|
||||
"shift_hours_batches_staff": "Horas de turno, lotes planificados y conteo de personal",
|
||||
"shift_hours": "Horas de Turno",
|
||||
"staff_count": "Conteo de Personal",
|
||||
"capacity_utilization": "Utilización de Capacidad",
|
||||
"planned_batches": "Lotes Planificados",
|
||||
"active": "Activo",
|
||||
"inactive": "Inactivo",
|
||||
"finalized": "Finalizado",
|
||||
"draft": "Borrador",
|
||||
"delayed": "retrasado",
|
||||
"staff_capacity": "Capacidad de Personal",
|
||||
"work_stations": "Estaciones de Trabajo"
|
||||
},
|
||||
"tracker": {
|
||||
"live_batch_tracker": "Rastreador de Lotes en Vivo",
|
||||
"current_batches_status_progress": "Lotes actuales con estado, barra de progreso y ETA",
|
||||
"progress": "Progreso",
|
||||
"pending_start": "Inicio Pendiente",
|
||||
"issues": "Problemas"
|
||||
},
|
||||
"insights": {
|
||||
"on_time_vs_planned": "Finalización de lotes vs horario planificado",
|
||||
"vs_week_avg": "vs promedio semanal",
|
||||
"target": "Objetivo",
|
||||
"progress_to_target": "Progreso al Objetivo",
|
||||
"weekly_trend": "Tendencia Semanal",
|
||||
"performance_insight": "Perspectiva de Rendimiento",
|
||||
"on_time_excellent": "¡Excelente rendimiento puntual! Continúa con el gran trabajo.",
|
||||
"on_time_good_room_improvement": "Buen rendimiento con espacio para mejoras.",
|
||||
"on_time_needs_attention": "La finalización puntual necesita atención. Revisar programación.",
|
||||
"on_time_critical_delays": "Retrasos críticos detectados. Se requiere acción inmediata.",
|
||||
"batches_on_time": "A Tiempo",
|
||||
"batches_delayed": "Retrasados",
|
||||
"avg_delay_minutes": "Retraso Promedio (min)",
|
||||
"resource_allocation_efficiency": "Monitorear asignación de recursos y eficiencia",
|
||||
"resource_breakdown": "Desglose de Recursos",
|
||||
"hourly_utilization_pattern": "Patrón de Utilización por Horas",
|
||||
"peak_hours": "Horas Pico",
|
||||
"off_peak_optimal": "Fuera de Pico (Óptimo)",
|
||||
"hourly_utilization": "Utilización por Horas"
|
||||
},
|
||||
"performance": {
|
||||
"excellent": "Excelente",
|
||||
"good": "Bueno",
|
||||
"average": "Promedio",
|
||||
"poor": "Pobre",
|
||||
"needs_improvement": "Necesita Mejora"
|
||||
},
|
||||
"recommendations": {
|
||||
"yield_improvement": "Oportunidad de Mejora de Rendimiento",
|
||||
"focus_on_low_performers": "Enfocarse en mejorar productos de bajo rendimiento para aumentar la eficiencia general.",
|
||||
"maintain_high_standards": "Continuar manteniendo altos estándares de rendimiento en todos los productos."
|
||||
},
|
||||
"common": {
|
||||
"units": "unidades",
|
||||
"assigned": "asignados",
|
||||
"allocated": "asignados",
|
||||
"available": "disponibles"
|
||||
},
|
||||
"alerts": {
|
||||
"capacity_critical": "Capacidad Crítica",
|
||||
"capacity_critical_description": "Algunos recursos están a máxima capacidad. Considerar redistribuir carga de trabajo.",
|
||||
"capacity_high": "Alta Utilización de Capacidad",
|
||||
"capacity_high_description": "Monitorear de cerca para prevenir cuellos de botella."
|
||||
},
|
||||
"mobile": {
|
||||
"optimized_experience": "Experiencia Optimizada para Móvil",
|
||||
"swipe_scroll_interact": "Desliza, desplázate y toca para interactuar con todos los widgets de análisis."
|
||||
},
|
||||
"ai": {
|
||||
"title": "Perspectivas de IA",
|
||||
"subtitle": "Recomendaciones y predicciones impulsadas por IA",
|
||||
"stats": {
|
||||
"active_insights": "Perspectivas Activas",
|
||||
"high_priority": "Alta Prioridad",
|
||||
"potential_savings": "Ahorros Potenciales",
|
||||
"avg_confidence": "Confianza Promedio"
|
||||
},
|
||||
"status": {
|
||||
"active": "Monitoreo IA activo",
|
||||
"new": "Nuevo",
|
||||
"acknowledged": "Reconocido",
|
||||
"in_progress": "En Progreso",
|
||||
"implemented": "Implementado",
|
||||
"dismissed": "Descartado"
|
||||
},
|
||||
"priority": {
|
||||
"low": "Baja",
|
||||
"medium": "Media",
|
||||
"high": "Alta",
|
||||
"critical": "Crítica"
|
||||
},
|
||||
"impact": {
|
||||
"cost_savings": "Ahorro de Costos",
|
||||
"efficiency_gain": "Ganancia de Eficiencia",
|
||||
"quality_improvement": "Mejora de Calidad",
|
||||
"risk_mitigation": "Mitigación de Riesgos"
|
||||
},
|
||||
"actions": {
|
||||
"train_model": "Entrenar Modelo",
|
||||
"implement_all": "Implementar Todo",
|
||||
"acknowledge": "Reconocer",
|
||||
"implement": "Implementar"
|
||||
},
|
||||
"confidence": "Confianza",
|
||||
"timeline": "Cronología",
|
||||
"equipment": "Equipo",
|
||||
"high_priority_insights": "Perspectivas de Alta Prioridad",
|
||||
"all_insights": "Todas las Perspectivas",
|
||||
"last_updated": "Actualizado hace 5m",
|
||||
"performance": {
|
||||
"summary": "Resumen de Rendimiento IA",
|
||||
"insights_implemented": "perspectivas implementadas este mes",
|
||||
"in_savings_identified": "en ahorros potenciales identificados"
|
||||
},
|
||||
"predictive_maintenance": {
|
||||
"title": "Mantenimiento Predictivo",
|
||||
"subtitle": "Predicción de fallos y optimización de mantenimiento impulsada por IA",
|
||||
"high_risk": "Equipos de Alto Riesgo",
|
||||
"estimated_cost": "Costo Estimado",
|
||||
"potential_downtime": "Tiempo de Inactividad Potencial",
|
||||
"avg_confidence": "Confianza Promedio",
|
||||
"high_risk_equipment": "Equipos de Alto Riesgo",
|
||||
"immediate_attention_required": "Se requiere atención inmediata para prevenir fallos",
|
||||
"predictions": "Predicciones de Mantenimiento",
|
||||
"confidence": "Confianza",
|
||||
"risk_score": "Puntuación de Riesgo",
|
||||
"days_until_failure": "Días Hasta Fallo",
|
||||
"condition": "Condición",
|
||||
"current_condition": "Condición Actual",
|
||||
"affected_components": "Componentes Afectados",
|
||||
"recommended_actions": "Acciones Recomendadas",
|
||||
"schedule": "Programar",
|
||||
"details": "Ver Detalles",
|
||||
"schedule_all": "Programar Todo",
|
||||
"retrain_model": "Reentrenar Modelo",
|
||||
"condition_trends": "Tendencias de Condición del Equipo",
|
||||
"risk_distribution": "Distribución de Riesgos",
|
||||
"model_status": "Estado del Modelo ML",
|
||||
"last_training": "Último entrenamiento",
|
||||
"yesterday": "ayer",
|
||||
"accuracy": "Precisión",
|
||||
"next_training": "Próximo entrenamiento",
|
||||
"in_7_days": "en 7 días",
|
||||
"alert_type": {
|
||||
"wear_prediction": "El patrón de desgaste indica fallo potencial",
|
||||
"failure_risk": "Alto riesgo de fallo detectado",
|
||||
"performance_degradation": "Degradación del rendimiento detectada",
|
||||
"component_replacement": "Se recomienda reemplazo de componentes"
|
||||
}
|
||||
},
|
||||
"severity": {
|
||||
"low": "Baja",
|
||||
"medium": "Media",
|
||||
"high": "Alta",
|
||||
"critical": "Crítica"
|
||||
},
|
||||
"risk": {
|
||||
"low": "Riesgo Bajo",
|
||||
"medium": "Riesgo Medio",
|
||||
"high": "Riesgo Alto"
|
||||
}
|
||||
},
|
||||
"equipment": {
|
||||
"title": "Estado del Equipo",
|
||||
"subtitle": "Monitorear salud y rendimiento del equipo",
|
||||
"status": {
|
||||
"operational": "Operacional",
|
||||
"warning": "Advertencia",
|
||||
"maintenance": "Mantenimiento",
|
||||
"down": "Inactivo",
|
||||
"equipment_list": "Lista de Equipos",
|
||||
"distribution": "Distribución de Estados"
|
||||
},
|
||||
"stats": {
|
||||
"total": "Total de Equipos",
|
||||
"operational": "Operacional",
|
||||
"needs_attention": "Necesita Atención",
|
||||
"efficiency": "Eficiencia",
|
||||
"avg_efficiency": "Eficiencia Promedio",
|
||||
"alerts": "Alertas Activas",
|
||||
"by_equipment": "por Equipo"
|
||||
},
|
||||
"efficiency": {
|
||||
"title": "Eficiencia del Equipo",
|
||||
"subtitle": "Monitorear rendimiento y oportunidades de optimización",
|
||||
"average": "Eficiencia Promedio",
|
||||
"current": "Actual",
|
||||
"target": "Objetivo",
|
||||
"current_vs_target": "Eficiencia Actual vs Objetivo",
|
||||
"weekly_trends": "Tendencias Semanales de Eficiencia",
|
||||
"by_equipment": "Eficiencia por Equipo",
|
||||
"analyze": "Analizar",
|
||||
"optimize": "Optimizar",
|
||||
"recommendations": "Recomendaciones de Eficiencia",
|
||||
"recommendation_1": "Acción Prioritaria",
|
||||
"recommendation_2": "Optimización Energética",
|
||||
"recommendation_3": "Cuidado Preventivo",
|
||||
"needs_maintenance": "requiere atención de mantenimiento",
|
||||
"optimize_energy_consumption": "Optimizar consumo energético durante horas pico",
|
||||
"schedule_preventive_maintenance": "Programar mantenimiento preventivo para todos los equipos"
|
||||
},
|
||||
"efficiency": "Eficiencia",
|
||||
"uptime": "Tiempo Activo",
|
||||
"temperature": "Temperatura",
|
||||
"energy_consumption": "Consumo Energético",
|
||||
"unread_alerts": "alertas no leídas",
|
||||
"units_per_hour": "unidades/hora",
|
||||
"downtime": {
|
||||
"total": "Tiempo de Inactividad Total"
|
||||
},
|
||||
"oee": {
|
||||
"overall": "OEE General",
|
||||
"breakdown": "Desglose OEE",
|
||||
"availability": "Disponibilidad",
|
||||
"performance": "Rendimiento",
|
||||
"quality": "Calidad"
|
||||
},
|
||||
"actions": {
|
||||
"schedule_maintenance": "Programar Mantenimiento",
|
||||
"add_task": "Agregar Tarea"
|
||||
},
|
||||
"maintenance": {
|
||||
"title": "Cronograma de Mantenimiento",
|
||||
"subtitle": "Planificar y rastrear actividades de mantenimiento",
|
||||
"scheduled": "Programado",
|
||||
"overdue": "Vencido",
|
||||
"avg_duration": "Duración Promedio",
|
||||
"total_cost": "Costo Total",
|
||||
"overdue_tasks": "Tareas Vencidas",
|
||||
"immediate_attention_required": "Se requiere atención inmediata",
|
||||
"tasks": "Tareas de Mantenimiento",
|
||||
"duration": "Duración",
|
||||
"cost": "Costo",
|
||||
"technician": "Técnico",
|
||||
"this_week": "Cronograma de Esta Semana",
|
||||
"insights": {
|
||||
"title": "Perspectivas de Mantenimiento",
|
||||
"completed_this_month": "tareas completadas este mes",
|
||||
"scheduled_next_week": "tareas programadas para la próxima semana"
|
||||
},
|
||||
"status": {
|
||||
"scheduled": "Programado",
|
||||
"in_progress": "En Progreso",
|
||||
"completed": "Completado",
|
||||
"overdue": "Vencido"
|
||||
},
|
||||
"type": {
|
||||
"preventive": "Preventivo",
|
||||
"corrective": "Correctivo",
|
||||
"inspection": "Inspección"
|
||||
}
|
||||
},
|
||||
"alerts": {
|
||||
"maintenance_required": "Mantenimiento Requerido",
|
||||
"equipment_in_maintenance": "equipos en mantenimiento",
|
||||
"active_alerts": "alertas activas"
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -5,7 +5,7 @@ import { statusColors } from '../../../../styles/colors';
|
||||
import { formatters } from '../../../../components/ui/Stats/StatsPresets';
|
||||
import { LoadingSpinner } from '../../../../components/shared';
|
||||
import { PageHeader } from '../../../../components/layout';
|
||||
import { ProductionSchedule, BatchTracker, QualityDashboard, EquipmentManager, CreateProductionBatchModal, ProductionStatusCard, QualityCheckModal, ProcessStageTracker, CompactProcessStageTracker } from '../../../../components/domain/production';
|
||||
import { ProductionSchedule, CreateProductionBatchModal, ProductionStatusCard, QualityCheckModal, CompactProcessStageTracker } from '../../../../components/domain/production';
|
||||
import { useCurrentTenant } from '../../../../stores/tenant.store';
|
||||
import {
|
||||
useProductionDashboard,
|
||||
|
||||
Reference in New Issue
Block a user