Add improved production UI
This commit is contained in:
408
frontend/src/components/domain/dashboard/AIInsightsWidget.tsx
Normal file
408
frontend/src/components/domain/dashboard/AIInsightsWidget.tsx
Normal file
@@ -0,0 +1,408 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Card, CardHeader, CardBody } from '../../ui/Card';
|
||||
import { Badge } from '../../ui/Badge';
|
||||
import { Button } from '../../ui/Button';
|
||||
import {
|
||||
Brain,
|
||||
TrendingUp,
|
||||
TrendingDown,
|
||||
AlertTriangle,
|
||||
Target,
|
||||
Lightbulb,
|
||||
BarChart3,
|
||||
Zap,
|
||||
Clock,
|
||||
DollarSign,
|
||||
Users,
|
||||
Package
|
||||
} from 'lucide-react';
|
||||
import { useCurrentTenant } from '../../../stores/tenant.store';
|
||||
import { useProductionDashboard } from '../../../api';
|
||||
|
||||
export interface AIInsight {
|
||||
id: string;
|
||||
type: 'optimization' | 'prediction' | 'alert' | 'recommendation';
|
||||
title: string;
|
||||
description: string;
|
||||
impact: 'high' | 'medium' | 'low';
|
||||
category: 'cost' | 'quality' | 'efficiency' | 'demand' | 'maintenance';
|
||||
confidence: number;
|
||||
potentialSavings?: number;
|
||||
timeToImplement?: string;
|
||||
actions: Array<{
|
||||
label: string;
|
||||
action: string;
|
||||
}>;
|
||||
data?: {
|
||||
current: number;
|
||||
predicted: number;
|
||||
unit: string;
|
||||
};
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface AIInsightsWidgetProps {
|
||||
className?: string;
|
||||
insights?: AIInsight[];
|
||||
onViewInsight?: (insightId: string) => void;
|
||||
onImplement?: (insightId: string, actionId: string) => void;
|
||||
onViewAll?: () => void;
|
||||
}
|
||||
|
||||
const AIInsightsWidget: React.FC<AIInsightsWidgetProps> = ({
|
||||
className,
|
||||
insights,
|
||||
onViewInsight,
|
||||
onImplement,
|
||||
onViewAll
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const currentTenant = useCurrentTenant();
|
||||
const tenantId = currentTenant?.id || '';
|
||||
|
||||
const {
|
||||
data: dashboardData,
|
||||
isLoading,
|
||||
error
|
||||
} = useProductionDashboard(tenantId);
|
||||
|
||||
const aiInsights = useMemo((): AIInsight[] => {
|
||||
if (insights) return insights;
|
||||
|
||||
// Mock AI-generated insights for demonstration
|
||||
return [
|
||||
{
|
||||
id: '1',
|
||||
type: 'optimization',
|
||||
title: t('ai.insights.reduce_energy_costs', 'Reduce Energy Costs by 15%'),
|
||||
description: t('ai.insights.energy_description', 'Adjust oven schedules to use off-peak electricity rates. Estimated savings: <20>45/day'),
|
||||
impact: 'high',
|
||||
category: 'cost',
|
||||
confidence: 87,
|
||||
potentialSavings: 1350,
|
||||
timeToImplement: '2 days',
|
||||
actions: [
|
||||
{ label: t('ai.actions.schedule_optimization', 'Optimize Schedule'), action: 'optimize_schedule' },
|
||||
{ label: t('ai.actions.view_details', 'View Details'), action: 'view_details' }
|
||||
],
|
||||
data: {
|
||||
current: 95.2,
|
||||
predicted: 81.0,
|
||||
unit: '<27>/day'
|
||||
},
|
||||
createdAt: '2024-01-23T08:30:00Z'
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
type: 'prediction',
|
||||
title: t('ai.insights.demand_increase', 'Croissant Demand Surge Predicted'),
|
||||
description: t('ai.insights.demand_description', 'Weather and event data suggest 40% increase in croissant demand this weekend'),
|
||||
impact: 'high',
|
||||
category: 'demand',
|
||||
confidence: 92,
|
||||
timeToImplement: '1 day',
|
||||
actions: [
|
||||
{ label: t('ai.actions.increase_production', 'Increase Production'), action: 'adjust_production' },
|
||||
{ label: t('ai.actions.order_ingredients', 'Order Ingredients'), action: 'order_ingredients' }
|
||||
],
|
||||
data: {
|
||||
current: 150,
|
||||
predicted: 210,
|
||||
unit: 'units/day'
|
||||
},
|
||||
createdAt: '2024-01-23T07:15:00Z'
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
type: 'alert',
|
||||
title: t('ai.insights.quality_decline', 'Quality Score Decline Detected'),
|
||||
description: t('ai.insights.quality_description', 'Bread quality has decreased by 8% over the last 3 days. Check flour moisture levels'),
|
||||
impact: 'medium',
|
||||
category: 'quality',
|
||||
confidence: 78,
|
||||
timeToImplement: '4 hours',
|
||||
actions: [
|
||||
{ label: t('ai.actions.check_ingredients', 'Check Ingredients'), action: 'quality_check' },
|
||||
{ label: t('ai.actions.adjust_recipe', 'Adjust Recipe'), action: 'recipe_adjustment' }
|
||||
],
|
||||
data: {
|
||||
current: 8.2,
|
||||
predicted: 7.5,
|
||||
unit: 'score'
|
||||
},
|
||||
createdAt: '2024-01-23T06:45:00Z'
|
||||
},
|
||||
{
|
||||
id: '4',
|
||||
type: 'recommendation',
|
||||
title: t('ai.insights.maintenance_due', 'Preventive Maintenance Recommended'),
|
||||
description: t('ai.insights.maintenance_description', 'Mixer #2 showing early wear patterns. Schedule maintenance to prevent breakdown'),
|
||||
impact: 'medium',
|
||||
category: 'maintenance',
|
||||
confidence: 85,
|
||||
potentialSavings: 800,
|
||||
timeToImplement: '1 week',
|
||||
actions: [
|
||||
{ label: t('ai.actions.schedule_maintenance', 'Schedule Maintenance'), action: 'schedule_maintenance' },
|
||||
{ label: t('ai.actions.view_equipment', 'View Equipment'), action: 'view_equipment' }
|
||||
],
|
||||
createdAt: '2024-01-22T14:20:00Z'
|
||||
}
|
||||
];
|
||||
}, [insights, t]);
|
||||
|
||||
const getInsightConfig = (type: AIInsight['type']) => {
|
||||
const configs = {
|
||||
optimization: {
|
||||
color: 'success' as const,
|
||||
icon: TrendingUp,
|
||||
bgColor: 'bg-green-50 dark:bg-green-900/20',
|
||||
borderColor: 'border-green-200 dark:border-green-800'
|
||||
},
|
||||
prediction: {
|
||||
color: 'info' as const,
|
||||
icon: BarChart3,
|
||||
bgColor: 'bg-blue-50 dark:bg-blue-900/20',
|
||||
borderColor: 'border-blue-200 dark:border-blue-800'
|
||||
},
|
||||
alert: {
|
||||
color: 'warning' as const,
|
||||
icon: AlertTriangle,
|
||||
bgColor: 'bg-orange-50 dark:bg-orange-900/20',
|
||||
borderColor: 'border-orange-200 dark:border-orange-800'
|
||||
},
|
||||
recommendation: {
|
||||
color: 'default' as const,
|
||||
icon: Lightbulb,
|
||||
bgColor: 'bg-purple-50 dark:bg-purple-900/20',
|
||||
borderColor: 'border-purple-200 dark:border-purple-800'
|
||||
}
|
||||
};
|
||||
return configs[type];
|
||||
};
|
||||
|
||||
const getCategoryIcon = (category: AIInsight['category']) => {
|
||||
const icons = {
|
||||
cost: DollarSign,
|
||||
quality: Target,
|
||||
efficiency: Zap,
|
||||
demand: BarChart3,
|
||||
maintenance: Users
|
||||
};
|
||||
return icons[category] || Package;
|
||||
};
|
||||
|
||||
const getImpactColor = (impact: AIInsight['impact']) => {
|
||||
const colors = {
|
||||
high: 'text-red-600',
|
||||
medium: 'text-orange-600',
|
||||
low: 'text-green-600'
|
||||
};
|
||||
return colors[impact];
|
||||
};
|
||||
|
||||
const formatCurrency = (amount: number) => {
|
||||
return new Intl.NumberFormat('es-ES', {
|
||||
style: 'currency',
|
||||
currency: 'EUR',
|
||||
minimumFractionDigits: 0
|
||||
}).format(amount);
|
||||
};
|
||||
|
||||
const highImpactInsights = aiInsights.filter(i => i.impact === 'high').length;
|
||||
const totalPotentialSavings = aiInsights.reduce((sum, i) => sum + (i.potentialSavings || 0), 0);
|
||||
const avgConfidence = aiInsights.reduce((sum, i) => sum + i.confidence, 0) / aiInsights.length;
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Card className={className}>
|
||||
<CardHeader>
|
||||
<h3 className="text-lg font-semibold text-[var(--text-primary)]">
|
||||
{t('ai.title', 'AI Insights')}
|
||||
</h3>
|
||||
</CardHeader>
|
||||
<CardBody>
|
||||
<div className="animate-pulse space-y-4">
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div className="h-12 bg-[var(--bg-secondary)] rounded"></div>
|
||||
<div className="h-12 bg-[var(--bg-secondary)] rounded"></div>
|
||||
<div className="h-12 bg-[var(--bg-secondary)] rounded"></div>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
<div className="h-20 bg-[var(--bg-secondary)] rounded"></div>
|
||||
<div className="h-20 bg-[var(--bg-secondary)] rounded"></div>
|
||||
</div>
|
||||
</div>
|
||||
</CardBody>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<Card className={className}>
|
||||
<CardBody className="text-center py-8">
|
||||
<Brain className="w-12 h-12 text-[var(--text-tertiary)] mx-auto mb-4" />
|
||||
<p className="text-[var(--text-secondary)]">
|
||||
{t('ai.error', 'AI insights temporarily unavailable')}
|
||||
</p>
|
||||
</CardBody>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className={className}>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Brain className="w-5 h-5 text-[var(--color-primary)]" />
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-[var(--text-primary)]">
|
||||
{t('ai.title', 'AI Insights')}
|
||||
</h3>
|
||||
<p className="text-sm text-[var(--text-secondary)]">
|
||||
{t('ai.subtitle', 'AI-powered recommendations and predictions')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={onViewAll}
|
||||
>
|
||||
{t('common.view_all', 'View All')}
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardBody className="space-y-6">
|
||||
{/* Summary Stats */}
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div className="text-center">
|
||||
<div className="text-2xl font-bold text-red-600">
|
||||
{highImpactInsights}
|
||||
</div>
|
||||
<div className="text-sm text-[var(--text-secondary)]">
|
||||
{t('ai.high_impact', 'High Impact')}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className="text-2xl font-bold text-green-600">
|
||||
{totalPotentialSavings > 0 ? formatCurrency(totalPotentialSavings) : ''}
|
||||
</div>
|
||||
<div className="text-sm text-[var(--text-secondary)]">
|
||||
{t('ai.potential_savings', 'Potential Savings')}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className="text-2xl font-bold text-[var(--color-primary)]">
|
||||
{avgConfidence.toFixed(0)}%
|
||||
</div>
|
||||
<div className="text-sm text-[var(--text-secondary)]">
|
||||
{t('ai.confidence', 'Avg Confidence')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Insights List */}
|
||||
<div className="space-y-3">
|
||||
{aiInsights.slice(0, 3).map((insight) => {
|
||||
const config = getInsightConfig(insight.type);
|
||||
const CategoryIcon = getCategoryIcon(insight.category);
|
||||
const TypeIcon = config.icon;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={insight.id}
|
||||
className={`p-4 rounded-lg border ${config.bgColor} ${config.borderColor} hover:shadow-sm transition-all cursor-pointer`}
|
||||
onClick={() => onViewInsight?.(insight.id)}
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-start space-x-3 flex-1">
|
||||
<div className="flex items-center space-x-1">
|
||||
<TypeIcon className="w-5 h-5 text-[var(--color-primary)]" />
|
||||
<CategoryIcon className="w-4 h-4 text-[var(--text-tertiary)]" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center space-x-2 mb-1">
|
||||
<h4 className="font-medium text-[var(--text-primary)] text-sm">
|
||||
{insight.title}
|
||||
</h4>
|
||||
<Badge variant={config.color} size="sm">
|
||||
{insight.confidence}%
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="text-sm text-[var(--text-secondary)] mb-2">
|
||||
{insight.description}
|
||||
</p>
|
||||
<div className="flex items-center space-x-4 text-xs text-[var(--text-tertiary)]">
|
||||
<span className={`font-medium ${getImpactColor(insight.impact)}`}>
|
||||
{insight.impact.toUpperCase()} {t('ai.impact', 'IMPACT')}
|
||||
</span>
|
||||
{insight.timeToImplement && (
|
||||
<span className="flex items-center space-x-1">
|
||||
<Clock className="w-3 h-3" />
|
||||
<span>{insight.timeToImplement}</span>
|
||||
</span>
|
||||
)}
|
||||
{insight.potentialSavings && (
|
||||
<span className="font-medium text-green-600">
|
||||
{formatCurrency(insight.potentialSavings)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{insight.data && (
|
||||
<div className="text-right ml-3">
|
||||
<div className="text-sm font-medium text-[var(--text-primary)]">
|
||||
{insight.data.current} <EFBFBD> {insight.data.predicted}
|
||||
</div>
|
||||
<div className="text-xs text-[var(--text-secondary)]">
|
||||
{insight.data.unit}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Action buttons */}
|
||||
<div className="flex items-center space-x-2 mt-3 pt-3 border-t border-[var(--border-primary)]">
|
||||
{insight.actions.slice(0, 2).map((action, index) => (
|
||||
<Button
|
||||
key={index}
|
||||
variant={index === 0 ? "primary" : "outline"}
|
||||
size="xs"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onImplement?.(insight.id, action.action);
|
||||
}}
|
||||
>
|
||||
{action.label}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* AI Status */}
|
||||
<div className="flex items-center justify-between pt-4 border-t border-[var(--border-primary)]">
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="w-2 h-2 bg-green-500 rounded-full animate-pulse"></div>
|
||||
<span className="text-sm text-[var(--text-secondary)]">
|
||||
{t('ai.status.active', 'AI monitoring active')}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-xs text-[var(--text-tertiary)]">
|
||||
{t('ai.last_updated', 'Updated 5m ago')}
|
||||
</div>
|
||||
</div>
|
||||
</CardBody>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default AIInsightsWidget;
|
||||
@@ -0,0 +1,392 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Card, CardHeader, CardBody } from '../../ui/Card';
|
||||
import { Badge } from '../../ui/Badge';
|
||||
import { Button } from '../../ui/Button';
|
||||
import {
|
||||
Settings,
|
||||
AlertTriangle,
|
||||
CheckCircle,
|
||||
Clock,
|
||||
Thermometer,
|
||||
Activity,
|
||||
Wrench,
|
||||
Zap,
|
||||
Calendar,
|
||||
TrendingUp
|
||||
} from 'lucide-react';
|
||||
import { useCurrentTenant } from '../../../stores/tenant.store';
|
||||
import { useProductionDashboard } from '../../../api';
|
||||
|
||||
export interface EquipmentStatus {
|
||||
id: string;
|
||||
name: string;
|
||||
type: 'oven' | 'mixer' | 'proofer' | 'freezer' | 'packaging';
|
||||
status: 'operational' | 'maintenance' | 'down' | 'warning';
|
||||
location: string;
|
||||
temperature?: number;
|
||||
targetTemperature?: number;
|
||||
efficiency: number;
|
||||
uptime: number;
|
||||
lastMaintenance: string;
|
||||
nextMaintenance: string;
|
||||
alerts: string[];
|
||||
energyUsage: number;
|
||||
utilizationToday: number;
|
||||
}
|
||||
|
||||
export interface EquipmentStatusWidgetProps {
|
||||
className?: string;
|
||||
equipment?: EquipmentStatus[];
|
||||
onViewEquipment?: (equipmentId: string) => void;
|
||||
onScheduleMaintenance?: (equipmentId: string) => void;
|
||||
onViewAll?: () => void;
|
||||
}
|
||||
|
||||
const EquipmentStatusWidget: React.FC<EquipmentStatusWidgetProps> = ({
|
||||
className,
|
||||
equipment,
|
||||
onViewEquipment,
|
||||
onScheduleMaintenance,
|
||||
onViewAll
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const currentTenant = useCurrentTenant();
|
||||
const tenantId = currentTenant?.id || '';
|
||||
|
||||
const {
|
||||
data: dashboardData,
|
||||
isLoading,
|
||||
error
|
||||
} = useProductionDashboard(tenantId);
|
||||
|
||||
const equipmentData = useMemo((): EquipmentStatus[] => {
|
||||
if (equipment) return equipment;
|
||||
|
||||
// Mock data for demonstration
|
||||
return [
|
||||
{
|
||||
id: '1',
|
||||
name: 'Horno Principal #1',
|
||||
type: 'oven',
|
||||
status: 'operational',
|
||||
location: '<27>rea de Horneado',
|
||||
temperature: 220,
|
||||
targetTemperature: 220,
|
||||
efficiency: 92,
|
||||
uptime: 98.5,
|
||||
lastMaintenance: '2024-01-15',
|
||||
nextMaintenance: '2024-03-15',
|
||||
alerts: [],
|
||||
energyUsage: 45.2,
|
||||
utilizationToday: 87
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
name: 'Batidora Industrial #2',
|
||||
type: 'mixer',
|
||||
status: 'warning',
|
||||
location: '<27>rea de Preparaci<63>n',
|
||||
efficiency: 88,
|
||||
uptime: 94.2,
|
||||
lastMaintenance: '2024-01-20',
|
||||
nextMaintenance: '2024-02-20',
|
||||
alerts: ['Vibraci<63>n inusual detectada', 'Revisar correas'],
|
||||
energyUsage: 12.8,
|
||||
utilizationToday: 76
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
name: 'C<>mara de Fermentaci<63>n #1',
|
||||
type: 'proofer',
|
||||
status: 'maintenance',
|
||||
location: '<27>rea de Fermentaci<63>n',
|
||||
temperature: 32,
|
||||
targetTemperature: 35,
|
||||
efficiency: 0,
|
||||
uptime: 85.1,
|
||||
lastMaintenance: '2024-01-23',
|
||||
nextMaintenance: '2024-01-24',
|
||||
alerts: ['En mantenimiento programado'],
|
||||
energyUsage: 0,
|
||||
utilizationToday: 0
|
||||
},
|
||||
{
|
||||
id: '4',
|
||||
name: 'Congelador R<>pido',
|
||||
type: 'freezer',
|
||||
status: 'operational',
|
||||
location: '<27>rea de Congelado',
|
||||
temperature: -18,
|
||||
targetTemperature: -18,
|
||||
efficiency: 95,
|
||||
uptime: 99.2,
|
||||
lastMaintenance: '2024-01-10',
|
||||
nextMaintenance: '2024-04-10',
|
||||
alerts: [],
|
||||
energyUsage: 23.5,
|
||||
utilizationToday: 65
|
||||
}
|
||||
];
|
||||
}, [equipment]);
|
||||
|
||||
const getStatusConfig = (status: EquipmentStatus['status']) => {
|
||||
const configs = {
|
||||
operational: {
|
||||
color: 'success' as const,
|
||||
icon: CheckCircle,
|
||||
label: t('equipment.status.operational', 'Operational')
|
||||
},
|
||||
warning: {
|
||||
color: 'warning' as const,
|
||||
icon: AlertTriangle,
|
||||
label: t('equipment.status.warning', 'Warning')
|
||||
},
|
||||
maintenance: {
|
||||
color: 'info' as const,
|
||||
icon: Wrench,
|
||||
label: t('equipment.status.maintenance', 'Maintenance')
|
||||
},
|
||||
down: {
|
||||
color: 'error' as const,
|
||||
icon: AlertTriangle,
|
||||
label: t('equipment.status.down', 'Down')
|
||||
}
|
||||
};
|
||||
return configs[status];
|
||||
};
|
||||
|
||||
const getEquipmentIcon = (type: EquipmentStatus['type']) => {
|
||||
const icons = {
|
||||
oven: Thermometer,
|
||||
mixer: Activity,
|
||||
proofer: Settings,
|
||||
freezer: Zap,
|
||||
packaging: Settings
|
||||
};
|
||||
return icons[type] || Settings;
|
||||
};
|
||||
|
||||
const summary = useMemo(() => {
|
||||
const total = equipmentData.length;
|
||||
const operational = equipmentData.filter(e => e.status === 'operational').length;
|
||||
const warning = equipmentData.filter(e => e.status === 'warning').length;
|
||||
const maintenance = equipmentData.filter(e => e.status === 'maintenance').length;
|
||||
const down = equipmentData.filter(e => e.status === 'down').length;
|
||||
const avgEfficiency = equipmentData.reduce((sum, e) => sum + e.efficiency, 0) / total;
|
||||
const avgUptime = equipmentData.reduce((sum, e) => sum + e.uptime, 0) / total;
|
||||
const totalEnergyUsage = equipmentData.reduce((sum, e) => sum + e.energyUsage, 0);
|
||||
|
||||
return {
|
||||
total,
|
||||
operational,
|
||||
warning,
|
||||
maintenance,
|
||||
down,
|
||||
avgEfficiency,
|
||||
avgUptime,
|
||||
totalEnergyUsage
|
||||
};
|
||||
}, [equipmentData]);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Card className={className}>
|
||||
<CardHeader>
|
||||
<h3 className="text-lg font-semibold text-[var(--text-primary)]">
|
||||
{t('equipment.title', 'Equipment Status')}
|
||||
</h3>
|
||||
</CardHeader>
|
||||
<CardBody>
|
||||
<div className="animate-pulse space-y-4">
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div className="h-12 bg-[var(--bg-secondary)] rounded"></div>
|
||||
<div className="h-12 bg-[var(--bg-secondary)] rounded"></div>
|
||||
<div className="h-12 bg-[var(--bg-secondary)] rounded"></div>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
<div className="h-16 bg-[var(--bg-secondary)] rounded"></div>
|
||||
<div className="h-16 bg-[var(--bg-secondary)] rounded"></div>
|
||||
</div>
|
||||
</div>
|
||||
</CardBody>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<Card className={className}>
|
||||
<CardBody className="text-center py-8">
|
||||
<AlertTriangle className="w-12 h-12 text-red-500 mx-auto mb-4" />
|
||||
<p className="text-[var(--text-secondary)]">
|
||||
{t('equipment.error', 'Error loading equipment data')}
|
||||
</p>
|
||||
</CardBody>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className={className}>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-[var(--text-primary)]">
|
||||
{t('equipment.title', 'Equipment Status')}
|
||||
</h3>
|
||||
<p className="text-sm text-[var(--text-secondary)]">
|
||||
{t('equipment.subtitle', 'Monitor equipment health and performance')}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={onViewAll}
|
||||
>
|
||||
{t('common.view_all', 'View All')}
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardBody className="space-y-6">
|
||||
{/* Summary Stats */}
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div className="text-center">
|
||||
<div className="text-2xl font-bold text-green-600">
|
||||
{summary.operational}
|
||||
</div>
|
||||
<div className="text-sm text-[var(--text-secondary)]">
|
||||
{t('equipment.operational', 'Operational')}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className="text-2xl font-bold text-orange-600">
|
||||
{summary.warning + summary.maintenance}
|
||||
</div>
|
||||
<div className="text-sm text-[var(--text-secondary)]">
|
||||
{t('equipment.needs_attention', 'Needs Attention')}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className="text-2xl font-bold text-[var(--color-primary)]">
|
||||
{summary.avgEfficiency.toFixed(1)}%
|
||||
</div>
|
||||
<div className="text-sm text-[var(--text-secondary)]">
|
||||
{t('equipment.efficiency', 'Efficiency')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Equipment List */}
|
||||
<div className="space-y-3">
|
||||
{equipmentData.slice(0, 4).map((item) => {
|
||||
const statusConfig = getStatusConfig(item.status);
|
||||
const EquipmentIcon = getEquipmentIcon(item.type);
|
||||
const StatusIcon = statusConfig.icon;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={item.id}
|
||||
className="flex items-center justify-between p-3 bg-[var(--bg-secondary)] rounded-lg hover:bg-[var(--bg-tertiary)] transition-colors cursor-pointer"
|
||||
onClick={() => onViewEquipment?.(item.id)}
|
||||
>
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="flex items-center space-x-2">
|
||||
<EquipmentIcon className="w-5 h-5 text-[var(--text-tertiary)]" />
|
||||
<StatusIcon className={`w-4 h-4 ${
|
||||
item.status === 'operational' ? 'text-green-500' :
|
||||
item.status === 'warning' ? 'text-orange-500' :
|
||||
item.status === 'maintenance' ? 'text-blue-500' :
|
||||
'text-red-500'
|
||||
}`} />
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-medium text-[var(--text-primary)] text-sm">
|
||||
{item.name}
|
||||
</div>
|
||||
<div className="text-xs text-[var(--text-secondary)]">
|
||||
{item.location}
|
||||
{item.temperature && (
|
||||
<span className="ml-2">
|
||||
{item.temperature}°C
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="text-right">
|
||||
<div className="text-sm font-medium text-[var(--text-primary)]">
|
||||
{item.efficiency}%
|
||||
</div>
|
||||
<div className="text-xs text-[var(--text-secondary)]">
|
||||
{t('equipment.efficiency', 'Efficiency')}
|
||||
</div>
|
||||
</div>
|
||||
<Badge variant={statusConfig.color}>
|
||||
{statusConfig.label}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Key Metrics */}
|
||||
<div className="grid grid-cols-2 gap-4 pt-4 border-t border-[var(--border-primary)]">
|
||||
<div className="text-center">
|
||||
<div className="flex items-center justify-center space-x-2 mb-1">
|
||||
<TrendingUp className="w-4 h-4 text-[var(--color-primary)]" />
|
||||
<span className="text-lg font-bold text-[var(--text-primary)]">
|
||||
{summary.avgUptime.toFixed(1)}%
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-sm text-[var(--text-secondary)]">
|
||||
{t('equipment.uptime', 'Average Uptime')}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className="flex items-center justify-center space-x-2 mb-1">
|
||||
<Zap className="w-4 h-4 text-yellow-500" />
|
||||
<span className="text-lg font-bold text-[var(--text-primary)]">
|
||||
{summary.totalEnergyUsage.toFixed(1)} kW
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-sm text-[var(--text-secondary)]">
|
||||
{t('equipment.energy_usage', 'Energy Usage')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Alerts */}
|
||||
{equipmentData.some(e => e.alerts.length > 0) && (
|
||||
<div className="space-y-2">
|
||||
<h4 className="text-sm font-medium text-[var(--text-primary)]">
|
||||
{t('equipment.alerts', 'Recent Alerts')}
|
||||
</h4>
|
||||
{equipmentData
|
||||
.filter(e => e.alerts.length > 0)
|
||||
.slice(0, 3)
|
||||
.map((item) => (
|
||||
<div key={item.id} className="flex items-start space-x-2 p-2 bg-orange-50 dark:bg-orange-900/20 rounded border-l-2 border-orange-500">
|
||||
<AlertTriangle className="w-4 h-4 text-orange-500 mt-0.5 flex-shrink-0" />
|
||||
<div>
|
||||
<div className="text-sm font-medium text-[var(--text-primary)]">
|
||||
{item.name}
|
||||
</div>
|
||||
<div className="text-xs text-[var(--text-secondary)]">
|
||||
{item.alerts[0]}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardBody>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default EquipmentStatusWidget;
|
||||
@@ -0,0 +1,322 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Card, CardHeader, CardBody } from '../../ui/Card';
|
||||
import { StatsGrid } from '../../ui/Stats';
|
||||
import { Badge } from '../../ui/Badge';
|
||||
import { Button } from '../../ui/Button';
|
||||
import {
|
||||
Euro,
|
||||
TrendingUp,
|
||||
TrendingDown,
|
||||
AlertTriangle,
|
||||
Target,
|
||||
Clock,
|
||||
Activity,
|
||||
Zap
|
||||
} from 'lucide-react';
|
||||
import { useCurrentTenant } from '../../../stores/tenant.store';
|
||||
import { useProductionDashboard } from '../../../api';
|
||||
|
||||
export interface ProductionCostData {
|
||||
totalCost: number;
|
||||
laborCost: number;
|
||||
materialCost: number;
|
||||
overheadCost: number;
|
||||
energyCost: number;
|
||||
costPerUnit: number;
|
||||
budget: number;
|
||||
variance: number;
|
||||
trend: {
|
||||
direction: 'up' | 'down' | 'stable';
|
||||
percentage: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface ProductionCostMonitorProps {
|
||||
className?: string;
|
||||
data?: ProductionCostData;
|
||||
onViewDetails?: () => void;
|
||||
onOptimize?: () => void;
|
||||
}
|
||||
|
||||
const ProductionCostMonitor: React.FC<ProductionCostMonitorProps> = ({
|
||||
className,
|
||||
data,
|
||||
onViewDetails,
|
||||
onOptimize
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const currentTenant = useCurrentTenant();
|
||||
const tenantId = currentTenant?.id || '';
|
||||
|
||||
const {
|
||||
data: dashboardData,
|
||||
isLoading,
|
||||
error
|
||||
} = useProductionDashboard(tenantId);
|
||||
|
||||
const costData = useMemo((): ProductionCostData => {
|
||||
if (data) return data;
|
||||
|
||||
// Calculate from dashboard data if available
|
||||
if (dashboardData) {
|
||||
const mockData: ProductionCostData = {
|
||||
totalCost: 2840.50,
|
||||
laborCost: 1200.00,
|
||||
materialCost: 1450.00,
|
||||
overheadCost: 190.50,
|
||||
energyCost: 95.25,
|
||||
costPerUnit: 14.20,
|
||||
budget: 3000.00,
|
||||
variance: -159.50,
|
||||
trend: {
|
||||
direction: 'down',
|
||||
percentage: 5.3
|
||||
}
|
||||
};
|
||||
return mockData;
|
||||
}
|
||||
|
||||
// Default mock data
|
||||
return {
|
||||
totalCost: 2840.50,
|
||||
laborCost: 1200.00,
|
||||
materialCost: 1450.00,
|
||||
overheadCost: 190.50,
|
||||
energyCost: 95.25,
|
||||
costPerUnit: 14.20,
|
||||
budget: 3000.00,
|
||||
variance: -159.50,
|
||||
trend: {
|
||||
direction: 'down',
|
||||
percentage: 5.3
|
||||
}
|
||||
};
|
||||
}, [data, dashboardData]);
|
||||
|
||||
const formatCurrency = (amount: number) => {
|
||||
return new Intl.NumberFormat('es-ES', {
|
||||
style: 'currency',
|
||||
currency: 'EUR',
|
||||
minimumFractionDigits: 2
|
||||
}).format(amount);
|
||||
};
|
||||
|
||||
const getVarianceColor = (variance: number) => {
|
||||
if (variance > 0) return 'text-red-600';
|
||||
if (variance < -50) return 'text-green-600';
|
||||
return 'text-yellow-600';
|
||||
};
|
||||
|
||||
const getBudgetStatus = () => {
|
||||
const percentage = (costData.totalCost / costData.budget) * 100;
|
||||
if (percentage > 100) return { color: 'error', label: t('production.cost.over_budget', 'Over Budget') };
|
||||
if (percentage > 90) return { color: 'warning', label: t('production.cost.near_budget', 'Near Budget') };
|
||||
return { color: 'success', label: t('production.cost.on_budget', 'On Budget') };
|
||||
};
|
||||
|
||||
const budgetStatus = getBudgetStatus();
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Card className={className}>
|
||||
<CardHeader>
|
||||
<h3 className="text-lg font-semibold text-[var(--text-primary)]">
|
||||
{t('production.cost.title', 'Production Cost Monitor')}
|
||||
</h3>
|
||||
</CardHeader>
|
||||
<CardBody>
|
||||
<div className="animate-pulse space-y-4">
|
||||
<div className="h-20 bg-[var(--bg-secondary)] rounded"></div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="h-16 bg-[var(--bg-secondary)] rounded"></div>
|
||||
<div className="h-16 bg-[var(--bg-secondary)] rounded"></div>
|
||||
</div>
|
||||
</div>
|
||||
</CardBody>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<Card className={className}>
|
||||
<CardBody className="text-center py-8">
|
||||
<AlertTriangle className="w-12 h-12 text-red-500 mx-auto mb-4" />
|
||||
<p className="text-[var(--text-secondary)]">
|
||||
{t('production.cost.error', 'Error loading cost data')}
|
||||
</p>
|
||||
</CardBody>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
const costStats = [
|
||||
{
|
||||
title: t('production.cost.total_cost', 'Total Cost'),
|
||||
value: formatCurrency(costData.totalCost),
|
||||
icon: Euro,
|
||||
variant: budgetStatus.color as 'success' | 'warning' | 'error',
|
||||
trend: {
|
||||
value: costData.trend.percentage,
|
||||
direction: costData.trend.direction === 'up' ? 'up' as const : 'down' as const,
|
||||
label: t('production.cost.vs_yesterday', 'vs yesterday')
|
||||
},
|
||||
subtitle: `${formatCurrency(Math.abs(costData.variance))} ${costData.variance > 0 ? t('common.over', 'over') : t('common.under', 'under')} ${t('common.budget', 'budget')}`
|
||||
},
|
||||
{
|
||||
title: t('production.cost.cost_per_unit', 'Cost per Unit'),
|
||||
value: formatCurrency(costData.costPerUnit),
|
||||
icon: Target,
|
||||
variant: 'info' as const,
|
||||
subtitle: t('production.cost.average_today', 'Average today')
|
||||
}
|
||||
];
|
||||
|
||||
const costBreakdown = [
|
||||
{
|
||||
label: t('production.cost.labor', 'Labor'),
|
||||
amount: costData.laborCost,
|
||||
percentage: (costData.laborCost / costData.totalCost) * 100,
|
||||
icon: Clock,
|
||||
color: 'bg-blue-500'
|
||||
},
|
||||
{
|
||||
label: t('production.cost.materials', 'Materials'),
|
||||
amount: costData.materialCost,
|
||||
percentage: (costData.materialCost / costData.totalCost) * 100,
|
||||
icon: Activity,
|
||||
color: 'bg-green-500'
|
||||
},
|
||||
{
|
||||
label: t('production.cost.overhead', 'Overhead'),
|
||||
amount: costData.overheadCost,
|
||||
percentage: (costData.overheadCost / costData.totalCost) * 100,
|
||||
icon: Euro,
|
||||
color: 'bg-orange-500'
|
||||
},
|
||||
{
|
||||
label: t('production.cost.energy', 'Energy'),
|
||||
amount: costData.energyCost,
|
||||
percentage: (costData.energyCost / costData.totalCost) * 100,
|
||||
icon: Zap,
|
||||
color: 'bg-yellow-500'
|
||||
}
|
||||
];
|
||||
|
||||
return (
|
||||
<Card className={className}>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-[var(--text-primary)]">
|
||||
{t('production.cost.title', 'Production Cost Monitor')}
|
||||
</h3>
|
||||
<p className="text-sm text-[var(--text-secondary)]">
|
||||
{t('production.cost.subtitle', 'Track and optimize production costs')}
|
||||
</p>
|
||||
</div>
|
||||
<Badge variant={budgetStatus.color as any}>
|
||||
{budgetStatus.label}
|
||||
</Badge>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardBody className="space-y-6">
|
||||
{/* Key Metrics */}
|
||||
<StatsGrid
|
||||
stats={costStats}
|
||||
columns={2}
|
||||
gap="md"
|
||||
/>
|
||||
|
||||
{/* Cost Breakdown */}
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-[var(--text-primary)] mb-4">
|
||||
{t('production.cost.breakdown', 'Cost Breakdown')}
|
||||
</h4>
|
||||
<div className="space-y-3">
|
||||
{costBreakdown.map((item, index) => {
|
||||
const Icon = item.icon;
|
||||
return (
|
||||
<div key={index} className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className={`w-3 h-3 rounded-full ${item.color}`}></div>
|
||||
<Icon className="w-4 h-4 text-[var(--text-tertiary)]" />
|
||||
<span className="text-sm text-[var(--text-primary)]">
|
||||
{item.label}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center space-x-3">
|
||||
<span className="text-sm font-medium text-[var(--text-primary)]">
|
||||
{formatCurrency(item.amount)}
|
||||
</span>
|
||||
<span className="text-xs text-[var(--text-tertiary)] w-10 text-right">
|
||||
{item.percentage.toFixed(1)}%
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Budget Progress */}
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-sm font-medium text-[var(--text-primary)]">
|
||||
{t('production.cost.budget_usage', 'Budget Usage')}
|
||||
</span>
|
||||
<span className="text-sm text-[var(--text-secondary)]">
|
||||
{formatCurrency(costData.totalCost)} / {formatCurrency(costData.budget)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="w-full bg-[var(--bg-secondary)] rounded-full h-2">
|
||||
<div
|
||||
className={`h-2 rounded-full transition-all duration-300 ${
|
||||
costData.totalCost > costData.budget
|
||||
? 'bg-red-500'
|
||||
: costData.totalCost > costData.budget * 0.9
|
||||
? 'bg-yellow-500'
|
||||
: 'bg-green-500'
|
||||
}`}
|
||||
style={{
|
||||
width: `${Math.min(100, (costData.totalCost / costData.budget) * 100)}%`
|
||||
}}
|
||||
></div>
|
||||
</div>
|
||||
<div className="flex items-center justify-between mt-2">
|
||||
<span className={`text-xs font-medium ${getVarianceColor(costData.variance)}`}>
|
||||
{costData.variance > 0 ? '+' : ''}{formatCurrency(costData.variance)}
|
||||
</span>
|
||||
<span className="text-xs text-[var(--text-tertiary)]">
|
||||
{((costData.totalCost / costData.budget) * 100).toFixed(1)}% {t('common.used', 'used')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex space-x-3 pt-4 border-t border-[var(--border-primary)]">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={onViewDetails}
|
||||
className="flex-1"
|
||||
>
|
||||
{t('production.cost.view_details', 'View Details')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
size="sm"
|
||||
onClick={onOptimize}
|
||||
className="flex-1"
|
||||
>
|
||||
{t('production.cost.optimize', 'Optimize Costs')}
|
||||
</Button>
|
||||
</div>
|
||||
</CardBody>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProductionCostMonitor;
|
||||
@@ -5,9 +5,12 @@ export { default as RealTimeAlerts } from './RealTimeAlerts';
|
||||
export { default as ProcurementPlansToday } from './ProcurementPlansToday';
|
||||
export { default as ProductionPlansToday } from './ProductionPlansToday';
|
||||
|
||||
// Note: The following components are specified in the design but not yet implemented:
|
||||
// - DashboardCard
|
||||
// - DashboardGrid
|
||||
// - QuickActions
|
||||
// - RecentActivity
|
||||
// - KPIWidget
|
||||
// Production Management Dashboard Widgets
|
||||
export { default as ProductionCostMonitor } from './ProductionCostMonitor';
|
||||
export { default as EquipmentStatusWidget } from './EquipmentStatusWidget';
|
||||
export { default as AIInsightsWidget } from './AIInsightsWidget';
|
||||
|
||||
// Types
|
||||
export type { ProductionCostData } from './ProductionCostMonitor';
|
||||
export type { EquipmentStatus } from './EquipmentStatusWidget';
|
||||
export type { AIInsight } from './AIInsightsWidget';
|
||||
@@ -24,5 +24,8 @@ export * from './analytics';
|
||||
// Onboarding components
|
||||
export * from './onboarding';
|
||||
|
||||
// Procurement components
|
||||
export * from './procurement';
|
||||
|
||||
// Team components
|
||||
export { default as AddTeamMemberModal } from './team/AddTeamMemberModal';
|
||||
@@ -0,0 +1,722 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Plus, Package, Euro, Calendar, Truck, Building2, X, Save, AlertCircle } from 'lucide-react';
|
||||
import { Button } from '../../ui/Button';
|
||||
import { Input } from '../../ui/Input';
|
||||
import { Card } from '../../ui/Card';
|
||||
import { useSuppliers } from '../../../api/hooks/suppliers';
|
||||
import { useCreatePurchaseOrder } from '../../../api/hooks/suppliers';
|
||||
import { useTenantStore } from '../../../stores/tenant.store';
|
||||
import type { ProcurementRequirementResponse, PurchaseOrderItem } from '../../../api/types/orders';
|
||||
import type { SupplierSummary } from '../../../api/types/suppliers';
|
||||
|
||||
interface CreatePurchaseOrderModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
requirements: ProcurementRequirementResponse[];
|
||||
onSuccess?: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* CreatePurchaseOrderModal - Modal for creating purchase orders from procurement requirements
|
||||
* Allows supplier selection and purchase order creation for ingredients
|
||||
* Can also be used for manual purchase order creation when no requirements are provided
|
||||
*/
|
||||
export const CreatePurchaseOrderModal: React.FC<CreatePurchaseOrderModalProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
requirements,
|
||||
onSuccess
|
||||
}) => {
|
||||
const [selectedSupplierId, setSelectedSupplierId] = useState<string>('');
|
||||
const [deliveryDate, setDeliveryDate] = useState<string>('');
|
||||
const [notes, setNotes] = useState<string>('');
|
||||
const [selectedRequirements, setSelectedRequirements] = useState<Record<string, boolean>>({});
|
||||
const [quantities, setQuantities] = useState<Record<string, number>>({});
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// For manual creation when no requirements are provided
|
||||
const [manualItems, setManualItems] = useState<Array<{
|
||||
id: string;
|
||||
product_name: string;
|
||||
product_sku?: string;
|
||||
unit_of_measure: string;
|
||||
unit_price: number;
|
||||
}>>([]);
|
||||
const [manualItemInputs, setManualItemInputs] = useState({
|
||||
product_name: '',
|
||||
product_sku: '',
|
||||
unit_of_measure: '',
|
||||
unit_price: '',
|
||||
quantity: ''
|
||||
});
|
||||
|
||||
// Get current tenant
|
||||
const { currentTenant } = useTenantStore();
|
||||
const tenantId = currentTenant?.id || '';
|
||||
|
||||
// Fetch suppliers (without status filter to avoid backend enum issue)
|
||||
const { data: suppliersData, isLoading: isLoadingSuppliers, isError: isSuppliersError, error: suppliersError } = useSuppliers(
|
||||
tenantId,
|
||||
{ limit: 100 },
|
||||
{ enabled: !!tenantId && isOpen }
|
||||
);
|
||||
const suppliers = (suppliersData?.data || []).filter(
|
||||
supplier => supplier.status === 'active'
|
||||
);
|
||||
|
||||
// Create purchase order mutation
|
||||
const createPurchaseOrderMutation = useCreatePurchaseOrder();
|
||||
|
||||
// Initialize quantities when requirements change
|
||||
useEffect(() => {
|
||||
if (requirements && requirements.length > 0) {
|
||||
// Initialize from requirements (existing behavior)
|
||||
const initialQuantities: Record<string, number> = {};
|
||||
requirements.forEach(req => {
|
||||
initialQuantities[req.id] = req.approved_quantity || req.net_requirement || req.required_quantity;
|
||||
});
|
||||
setQuantities(initialQuantities);
|
||||
|
||||
// Initialize all requirements as selected
|
||||
const initialSelected: Record<string, boolean> = {};
|
||||
requirements.forEach(req => {
|
||||
initialSelected[req.id] = true;
|
||||
});
|
||||
setSelectedRequirements(initialSelected);
|
||||
|
||||
// Clear manual items when using requirements
|
||||
setManualItems([]);
|
||||
} else {
|
||||
// Reset for manual creation
|
||||
setQuantities({});
|
||||
setSelectedRequirements({});
|
||||
setManualItems([]);
|
||||
}
|
||||
}, [requirements]);
|
||||
|
||||
// Group requirements by supplier (only when requirements exist)
|
||||
const groupedRequirements = requirements && requirements.length > 0 ?
|
||||
requirements.reduce((acc, req) => {
|
||||
const supplierId = req.preferred_supplier_id || 'unassigned';
|
||||
if (!acc[supplierId]) {
|
||||
acc[supplierId] = [];
|
||||
}
|
||||
acc[supplierId].push(req);
|
||||
return acc;
|
||||
}, {} as Record<string, ProcurementRequirementResponse[]>) :
|
||||
{};
|
||||
|
||||
const handleQuantityChange = (requirementId: string, value: string) => {
|
||||
const numValue = parseFloat(value) || 0;
|
||||
setQuantities(prev => ({
|
||||
...prev,
|
||||
[requirementId]: numValue
|
||||
}));
|
||||
};
|
||||
|
||||
const handleSelectRequirement = (requirementId: string, checked: boolean) => {
|
||||
setSelectedRequirements(prev => ({
|
||||
...prev,
|
||||
[requirementId]: checked
|
||||
}));
|
||||
};
|
||||
|
||||
const handleSelectAll = (supplierId: string, checked: boolean) => {
|
||||
const supplierRequirements = groupedRequirements[supplierId] || [];
|
||||
const updatedSelected = { ...selectedRequirements };
|
||||
|
||||
supplierRequirements.forEach(req => {
|
||||
updatedSelected[req.id] = checked;
|
||||
});
|
||||
|
||||
setSelectedRequirements(updatedSelected);
|
||||
};
|
||||
|
||||
// Manual item functions
|
||||
const handleAddManualItem = () => {
|
||||
if (!manualItemInputs.product_name || !manualItemInputs.unit_of_measure || !manualItemInputs.unit_price) {
|
||||
return;
|
||||
}
|
||||
|
||||
const newItem = {
|
||||
id: `manual-${Date.now()}`,
|
||||
product_name: manualItemInputs.product_name,
|
||||
product_sku: manualItemInputs.product_sku || undefined,
|
||||
unit_of_measure: manualItemInputs.unit_of_measure,
|
||||
unit_price: parseFloat(manualItemInputs.unit_price) || 0
|
||||
};
|
||||
|
||||
setManualItems(prev => [...prev, newItem]);
|
||||
|
||||
// Reset inputs
|
||||
setManualItemInputs({
|
||||
product_name: '',
|
||||
product_sku: '',
|
||||
unit_of_measure: '',
|
||||
unit_price: '',
|
||||
quantity: ''
|
||||
});
|
||||
};
|
||||
|
||||
const handleRemoveManualItem = (id: string) => {
|
||||
setManualItems(prev => prev.filter(item => item.id !== id));
|
||||
};
|
||||
|
||||
const handleManualItemQuantityChange = (id: string, value: string) => {
|
||||
const numValue = parseFloat(value) || 0;
|
||||
setQuantities(prev => ({
|
||||
...prev,
|
||||
[id]: numValue
|
||||
}));
|
||||
};
|
||||
|
||||
const handleCreatePurchaseOrder = async () => {
|
||||
if (!selectedSupplierId) {
|
||||
setError('Por favor, selecciona un proveedor');
|
||||
return;
|
||||
}
|
||||
|
||||
let items: PurchaseOrderItem[] = [];
|
||||
|
||||
if (requirements && requirements.length > 0) {
|
||||
// Create items from requirements
|
||||
const selectedReqs = requirements.filter(req => selectedRequirements[req.id]);
|
||||
|
||||
if (selectedReqs.length === 0) {
|
||||
setError('Por favor, selecciona al menos un ingrediente');
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate quantities
|
||||
const invalidQuantities = selectedReqs.some(req => quantities[req.id] <= 0);
|
||||
if (invalidQuantities) {
|
||||
setError('Todas las cantidades deben ser mayores a 0');
|
||||
return;
|
||||
}
|
||||
|
||||
// Prepare purchase order items from requirements
|
||||
items = selectedReqs.map(req => ({
|
||||
inventory_product_id: req.product_id,
|
||||
product_code: req.product_sku || '',
|
||||
product_name: req.product_name,
|
||||
ordered_quantity: quantities[req.id],
|
||||
unit_of_measure: req.unit_of_measure,
|
||||
unit_price: req.estimated_unit_cost || 0,
|
||||
quality_requirements: req.quality_specifications ? JSON.stringify(req.quality_specifications) : undefined,
|
||||
notes: req.special_requirements || undefined
|
||||
}));
|
||||
} else {
|
||||
// Create items from manual entries
|
||||
if (manualItems.length === 0) {
|
||||
setError('Por favor, agrega al menos un producto');
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate quantities for manual items
|
||||
const invalidQuantities = manualItems.some(item => quantities[item.id] <= 0);
|
||||
if (invalidQuantities) {
|
||||
setError('Todas las cantidades deben ser mayores a 0');
|
||||
return;
|
||||
}
|
||||
|
||||
// Prepare purchase order items from manual entries
|
||||
items = manualItems.map(item => ({
|
||||
inventory_product_id: '', // Not applicable for manual items
|
||||
product_code: item.product_sku || '',
|
||||
product_name: item.product_name,
|
||||
ordered_quantity: quantities[item.id],
|
||||
unit_of_measure: item.unit_of_measure,
|
||||
unit_price: item.unit_price,
|
||||
quality_requirements: undefined,
|
||||
notes: undefined
|
||||
}));
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
// Create purchase order
|
||||
await createPurchaseOrderMutation.mutateAsync({
|
||||
supplier_id: selectedSupplierId,
|
||||
priority: 'normal',
|
||||
required_delivery_date: deliveryDate || undefined,
|
||||
notes: notes || undefined,
|
||||
items
|
||||
});
|
||||
|
||||
// Close modal and trigger success callback
|
||||
onClose();
|
||||
if (onSuccess) {
|
||||
onSuccess();
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error creating purchase order:', err);
|
||||
setError('Error al crear la orden de compra. Por favor, intenta de nuevo.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Log suppliers when they change for debugging
|
||||
useEffect(() => {
|
||||
// console.log('Suppliers updated:', suppliers);
|
||||
}, [suppliers]);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed top-[var(--header-height)] left-0 right-0 bottom-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
||||
<div className="bg-[var(--bg-primary)] rounded-lg shadow-xl max-w-4xl w-full max-h-[90vh] overflow-hidden mx-4">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between p-6 border-b border-[var(--border-primary)]">
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="flex items-center justify-center w-8 h-8 rounded-full bg-blue-100">
|
||||
<Plus className="w-5 h-5 text-blue-600" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-[var(--text-primary)]">
|
||||
Crear Orden de Compra
|
||||
</h2>
|
||||
<p className="text-sm text-[var(--text-secondary)]">
|
||||
Selecciona proveedor e ingredientes para crear una orden de compra
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={onClose}
|
||||
className="p-2"
|
||||
disabled={loading}
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="p-6 overflow-y-auto max-h-[calc(90vh-200px)]">
|
||||
{error && (
|
||||
<div className="mb-4 p-3 bg-red-50 border border-red-200 rounded-lg flex items-center">
|
||||
<AlertCircle className="w-5 h-5 text-red-500 mr-2 flex-shrink-0" />
|
||||
<span className="text-red-700 text-sm">{error}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Supplier Selection */}
|
||||
<div className="mb-6">
|
||||
<label className="block text-sm font-medium text-[var(--text-secondary)] mb-2">
|
||||
Proveedor
|
||||
</label>
|
||||
{!tenantId ? (
|
||||
<div className="p-3 bg-yellow-50 border border-yellow-200 rounded-lg text-yellow-700 text-sm">
|
||||
Cargando información del tenant...
|
||||
</div>
|
||||
) : isLoadingSuppliers ? (
|
||||
<div className="animate-pulse h-10 bg-gray-200 rounded"></div>
|
||||
) : isSuppliersError ? (
|
||||
<div className="p-3 bg-red-50 border border-red-200 rounded-lg text-red-700 text-sm">
|
||||
Error al cargar proveedores: {suppliersError?.message || 'Error desconocido'}
|
||||
</div>
|
||||
) : (
|
||||
<select
|
||||
value={selectedSupplierId}
|
||||
onChange={(e) => setSelectedSupplierId(e.target.value)}
|
||||
className="w-full px-3 py-2 text-sm border border-[var(--border-primary)] rounded-lg
|
||||
bg-[var(--bg-primary)] text-[var(--text-primary)]
|
||||
focus:ring-2 focus:ring-[var(--color-primary)]/20 focus:border-[var(--color-primary)]
|
||||
transition-colors duration-200"
|
||||
disabled={loading}
|
||||
>
|
||||
<option value="">Seleccionar proveedor...</option>
|
||||
{suppliers.length > 0 ? (
|
||||
suppliers.map((supplier: SupplierSummary) => (
|
||||
<option key={supplier.id} value={supplier.id}>
|
||||
{supplier.name} ({supplier.supplier_code})
|
||||
</option>
|
||||
))
|
||||
) : (
|
||||
<option value="" disabled>
|
||||
No hay proveedores activos disponibles
|
||||
</option>
|
||||
)}
|
||||
</select>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Delivery Date */}
|
||||
<div className="mb-6">
|
||||
<label className="block text-sm font-medium text-[var(--text-secondary)] mb-2">
|
||||
Fecha de Entrega Requerida (Opcional)
|
||||
</label>
|
||||
<Input
|
||||
type="date"
|
||||
value={deliveryDate}
|
||||
onChange={(e) => setDeliveryDate(e.target.value)}
|
||||
className="w-full"
|
||||
disabled={loading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Notes */}
|
||||
<div className="mb-6">
|
||||
<label className="block text-sm font-medium text-[var(--text-secondary)] mb-2">
|
||||
Notas (Opcional)
|
||||
</label>
|
||||
<textarea
|
||||
value={notes}
|
||||
onChange={(e) => setNotes(e.target.value)}
|
||||
placeholder="Instrucciones especiales para el proveedor..."
|
||||
className="w-full px-3 py-2 text-sm border border-[var(--border-primary)] rounded-lg
|
||||
bg-[var(--bg-primary)] text-[var(--text-primary)] placeholder-[var(--text-tertiary)]
|
||||
focus:ring-2 focus:ring-[var(--color-primary)]/20 focus:border-[var(--color-primary)]
|
||||
transition-colors duration-200 resize-vertical min-h-[80px]"
|
||||
disabled={loading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Requirements by Supplier or Manual Items */}
|
||||
<div className="space-y-6">
|
||||
<h3 className="text-md font-semibold text-[var(--text-primary)] border-b pb-2">
|
||||
{requirements && requirements.length > 0 ? 'Ingredientes a Comprar' : 'Productos a Comprar'}
|
||||
</h3>
|
||||
|
||||
{requirements && requirements.length > 0 ? (
|
||||
// Show requirements when they exist
|
||||
Object.entries(groupedRequirements).map(([supplierId, reqs]) => {
|
||||
const supplierName = supplierId === 'unassigned'
|
||||
? 'Sin proveedor asignado'
|
||||
: suppliers.find(s => s.id === supplierId)?.name || 'Proveedor desconocido';
|
||||
|
||||
const allSelected = reqs.every(req => selectedRequirements[req.id]);
|
||||
const someSelected = reqs.some(req => selectedRequirements[req.id]);
|
||||
|
||||
return (
|
||||
<Card key={supplierId} className="p-4">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Building2 className="w-4 h-4 text-[var(--text-secondary)]" />
|
||||
<h4 className="font-medium text-[var(--text-primary)]">{supplierName}</h4>
|
||||
<span className="text-xs text-[var(--text-secondary)] bg-[var(--bg-secondary)] px-2 py-1 rounded">
|
||||
{reqs.length} {reqs.length === 1 ? 'ingrediente' : 'ingredientes'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
id={`select-all-${supplierId}`}
|
||||
checked={allSelected}
|
||||
ref={el => {
|
||||
if (el) el.indeterminate = someSelected && !allSelected;
|
||||
}}
|
||||
onChange={(e) => handleSelectAll(supplierId, e.target.checked)}
|
||||
className="w-4 h-4 text-blue-600 bg-gray-100 border-gray-300 rounded focus:ring-blue-500"
|
||||
disabled={loading}
|
||||
/>
|
||||
<label
|
||||
htmlFor={`select-all-${supplierId}`}
|
||||
className="ml-2 text-sm text-[var(--text-secondary)]"
|
||||
>
|
||||
Seleccionar todo
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
{reqs.map((req) => (
|
||||
<div
|
||||
key={req.id}
|
||||
className={`flex items-center p-3 rounded-lg border ${
|
||||
selectedRequirements[req.id]
|
||||
? 'border-[var(--color-primary)] bg-[var(--color-primary)]/5'
|
||||
: 'border-[var(--border-primary)]'
|
||||
}`}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={!!selectedRequirements[req.id]}
|
||||
onChange={(e) => handleSelectRequirement(req.id, e.target.checked)}
|
||||
className="w-4 h-4 text-blue-600 bg-gray-100 border-gray-300 rounded focus:ring-blue-500"
|
||||
disabled={loading}
|
||||
/>
|
||||
<div className="ml-3 flex-1 min-w-0">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-medium text-[var(--text-primary)] truncate">
|
||||
{req.product_name}
|
||||
</p>
|
||||
<p className="text-xs text-[var(--text-secondary)]">
|
||||
{req.product_sku || 'Sin SKU'} • {req.unit_of_measure}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="flex items-center">
|
||||
<Euro className="w-3 h-3 text-[var(--text-secondary)] mr-1" />
|
||||
<span className="text-xs text-[var(--text-secondary)]">
|
||||
{req.estimated_unit_cost?.toFixed(2) || '0.00'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-2 flex items-center space-x-2">
|
||||
<div className="flex-1">
|
||||
<label className="block text-xs text-[var(--text-secondary)] mb-1">
|
||||
Cantidad requerida
|
||||
</label>
|
||||
<div className="flex items-center">
|
||||
<Input
|
||||
type="number"
|
||||
min="0"
|
||||
step="0.01"
|
||||
value={quantities[req.id] || ''}
|
||||
onChange={(e) => handleQuantityChange(req.id, e.target.value)}
|
||||
className="w-24 text-sm"
|
||||
disabled={loading || !selectedRequirements[req.id]}
|
||||
/>
|
||||
<span className="ml-2 text-sm text-[var(--text-secondary)]">
|
||||
{req.unit_of_measure}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1">
|
||||
<label className="block text-xs text-[var(--text-secondary)] mb-1">
|
||||
Stock actual
|
||||
</label>
|
||||
<div className="flex items-center">
|
||||
<span className="text-sm text-[var(--text-primary)]">
|
||||
{req.current_stock_level || 0}
|
||||
</span>
|
||||
<span className="ml-1 text-xs text-[var(--text-secondary)]">
|
||||
{req.unit_of_measure}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1">
|
||||
<label className="block text-xs text-[var(--text-secondary)] mb-1">
|
||||
Total estimado
|
||||
</label>
|
||||
<div className="flex items-center">
|
||||
<Euro className="w-3 h-3 text-[var(--text-secondary)] mr-1" />
|
||||
<span className="text-sm text-[var(--text-primary)]">
|
||||
{((quantities[req.id] || 0) * (req.estimated_unit_cost || 0)).toFixed(2)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
// Show manual item creation when no requirements exist
|
||||
<Card className="p-4">
|
||||
<div className="space-y-4">
|
||||
{/* Manual Item Input Form */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-5 gap-3">
|
||||
<div className="md:col-span-2">
|
||||
<label className="block text-xs font-medium text-[var(--text-secondary)] mb-1">
|
||||
Nombre del Producto *
|
||||
</label>
|
||||
<Input
|
||||
type="text"
|
||||
value={manualItemInputs.product_name}
|
||||
onChange={(e) => setManualItemInputs(prev => ({
|
||||
...prev,
|
||||
product_name: e.target.value
|
||||
}))}
|
||||
placeholder="Harina de Trigo"
|
||||
className="w-full text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-[var(--text-secondary)] mb-1">
|
||||
SKU
|
||||
</label>
|
||||
<Input
|
||||
type="text"
|
||||
value={manualItemInputs.product_sku}
|
||||
onChange={(e) => setManualItemInputs(prev => ({
|
||||
...prev,
|
||||
product_sku: e.target.value
|
||||
}))}
|
||||
placeholder="HT-001"
|
||||
className="w-full text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-[var(--text-secondary)] mb-1">
|
||||
Unidad *
|
||||
</label>
|
||||
<Input
|
||||
type="text"
|
||||
value={manualItemInputs.unit_of_measure}
|
||||
onChange={(e) => setManualItemInputs(prev => ({
|
||||
...prev,
|
||||
unit_of_measure: e.target.value
|
||||
}))}
|
||||
placeholder="kg"
|
||||
className="w-full text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-[var(--text-secondary)] mb-1">
|
||||
Precio Unitario *
|
||||
</label>
|
||||
<div className="relative">
|
||||
<span className="absolute left-2 top-1/2 transform -translate-y-1/2 text-[var(--text-secondary)] text-sm">€</span>
|
||||
<Input
|
||||
type="number"
|
||||
min="0"
|
||||
step="0.01"
|
||||
value={manualItemInputs.unit_price}
|
||||
onChange={(e) => setManualItemInputs(prev => ({
|
||||
...prev,
|
||||
unit_price: e.target.value
|
||||
}))}
|
||||
placeholder="2.50"
|
||||
className="w-full text-sm pl-6"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleAddManualItem}
|
||||
disabled={!manualItemInputs.product_name || !manualItemInputs.unit_of_measure || !manualItemInputs.unit_price}
|
||||
>
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
Agregar Producto
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Manual Items List */}
|
||||
{manualItems.length > 0 && (
|
||||
<div className="mt-4 space-y-3">
|
||||
<h4 className="text-sm font-medium text-[var(--text-primary)]">
|
||||
Productos Agregados ({manualItems.length})
|
||||
</h4>
|
||||
|
||||
{manualItems.map((item) => (
|
||||
<div
|
||||
key={item.id}
|
||||
className="flex items-center p-3 rounded-lg border border-[var(--border-primary)]"
|
||||
>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-medium text-[var(--text-primary)] truncate">
|
||||
{item.product_name}
|
||||
</p>
|
||||
<p className="text-xs text-[var(--text-secondary)]">
|
||||
{item.product_sku || 'Sin SKU'} • {item.unit_of_measure}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="flex items-center">
|
||||
<Euro className="w-3 h-3 text-[var(--text-secondary)] mr-1" />
|
||||
<span className="text-xs text-[var(--text-secondary)]">
|
||||
{item.unit_price.toFixed(2)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-2 flex items-center space-x-2">
|
||||
<div className="flex-1">
|
||||
<label className="block text-xs text-[var(--text-secondary)] mb-1">
|
||||
Cantidad
|
||||
</label>
|
||||
<div className="flex items-center">
|
||||
<Input
|
||||
type="number"
|
||||
min="0"
|
||||
step="0.01"
|
||||
value={quantities[item.id] || ''}
|
||||
onChange={(e) => handleManualItemQuantityChange(item.id, e.target.value)}
|
||||
className="w-24 text-sm"
|
||||
disabled={loading}
|
||||
/>
|
||||
<span className="ml-2 text-sm text-[var(--text-secondary)]">
|
||||
{item.unit_of_measure}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1">
|
||||
<label className="block text-xs text-[var(--text-secondary)] mb-1">
|
||||
Total
|
||||
</label>
|
||||
<div className="flex items-center">
|
||||
<Euro className="w-3 h-3 text-[var(--text-secondary)] mr-1" />
|
||||
<span className="text-sm text-[var(--text-primary)]">
|
||||
{((quantities[item.id] || 0) * item.unit_price).toFixed(2)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleRemoveManualItem(item.id)}
|
||||
disabled={loading}
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex justify-end space-x-3 p-6 border-t border-[var(--border-primary)]">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={onClose}
|
||||
disabled={loading}
|
||||
>
|
||||
Cancelar
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={handleCreatePurchaseOrder}
|
||||
disabled={loading || !selectedSupplierId}
|
||||
>
|
||||
{loading ? (
|
||||
<>
|
||||
<div className="w-4 h-4 mr-2 border-2 border-white border-t-transparent rounded-full animate-spin"></div>
|
||||
Creando...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Save className="w-4 h-4 mr-2" />
|
||||
Crear Orden de Compra
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default CreatePurchaseOrderModal;
|
||||
3
frontend/src/components/domain/procurement/index.ts
Normal file
3
frontend/src/components/domain/procurement/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
// Procurement Components - Components for procurement and purchase order management
|
||||
|
||||
export { default as CreatePurchaseOrderModal } from './CreatePurchaseOrderModal';
|
||||
632
frontend/src/components/domain/production/EquipmentManager.tsx
Normal file
632
frontend/src/components/domain/production/EquipmentManager.tsx
Normal file
@@ -0,0 +1,632 @@
|
||||
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;
|
||||
467
frontend/src/components/domain/production/QualityDashboard.tsx
Normal file
467
frontend/src/components/domain/production/QualityDashboard.tsx
Normal file
@@ -0,0 +1,467 @@
|
||||
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;
|
||||
585
frontend/src/components/domain/production/QualityInspection.tsx
Normal file
585
frontend/src/components/domain/production/QualityInspection.tsx
Normal file
@@ -0,0 +1,585 @@
|
||||
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;
|
||||
@@ -2,9 +2,15 @@
|
||||
export { default as ProductionSchedule } from './ProductionSchedule';
|
||||
export { default as BatchTracker } from './BatchTracker';
|
||||
export { default as QualityControl } from './QualityControl';
|
||||
export { default as QualityDashboard } from './QualityDashboard';
|
||||
export { default as QualityInspection } from './QualityInspection';
|
||||
export { default as EquipmentManager } from './EquipmentManager';
|
||||
export { CreateProductionBatchModal } from './CreateProductionBatchModal';
|
||||
|
||||
// Export component props types
|
||||
export type { ProductionScheduleProps } from './ProductionSchedule';
|
||||
export type { BatchTrackerProps } from './BatchTracker';
|
||||
export type { QualityControlProps } from './QualityControl';
|
||||
export type { QualityControlProps } from './QualityControl';
|
||||
export type { QualityDashboardProps, QualityMetrics } from './QualityDashboard';
|
||||
export type { QualityInspectionProps, InspectionCriteria, InspectionResult } from './QualityInspection';
|
||||
export type { EquipmentManagerProps, Equipment } from './EquipmentManager';
|
||||
Reference in New Issue
Block a user