Refactor components and modals
This commit is contained in:
@@ -1,8 +1,8 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Building2, Settings, Wrench, Thermometer, Activity, CheckCircle, AlertTriangle, Eye, Edit } from 'lucide-react';
|
||||
import { StatusModal, StatusModalSection } from '../../ui/StatusModal/StatusModal';
|
||||
import { Equipment } from '../../../types/equipment';
|
||||
import { EditViewModal, StatusModalSection } from '../../ui/EditViewModal/EditViewModal';
|
||||
import { Equipment } from '../../../api/types/equipment';
|
||||
|
||||
interface EquipmentModalProps {
|
||||
isOpen: boolean;
|
||||
@@ -337,7 +337,7 @@ export const EquipmentModal: React.FC<EquipmentModalProps> = ({
|
||||
};
|
||||
|
||||
return (
|
||||
<StatusModal
|
||||
<EditViewModal
|
||||
isOpen={isOpen}
|
||||
onClose={() => {
|
||||
onClose();
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React from 'react';
|
||||
import { StatusModal, StatusModalSection } from '@/components/ui/StatusModal/StatusModal';
|
||||
import { EditViewModal, StatusModalSection } from '@/components/ui/EditViewModal/EditViewModal';
|
||||
import { Badge } from '@/components/ui/Badge';
|
||||
import { Tooltip } from '@/components/ui/Tooltip';
|
||||
import { TrainedModelResponse, TrainingMetrics } from '@/types/training';
|
||||
@@ -94,7 +94,7 @@ const ModelDetailsModal: React.FC<ModelDetailsModalProps> = ({
|
||||
// Early return if model is not provided
|
||||
if (!model) {
|
||||
return (
|
||||
<StatusModal
|
||||
<EditViewModal
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
mode="view"
|
||||
@@ -387,7 +387,7 @@ const ModelDetailsModal: React.FC<ModelDetailsModalProps> = ({
|
||||
}
|
||||
|
||||
return (
|
||||
<StatusModal
|
||||
<EditViewModal
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
mode="view"
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Plus, Package, Euro, Calendar, FileText, Thermometer } from 'lucide-react';
|
||||
import { StatusModal } from '../../ui/StatusModal/StatusModal';
|
||||
import { EditViewModal } from '../../ui/EditViewModal/EditViewModal';
|
||||
import { IngredientResponse, StockCreate, ProductionStage } from '../../../api/types/inventory';
|
||||
import { Button } from '../../ui/Button';
|
||||
import { useSuppliers } from '../../../api/hooks/suppliers';
|
||||
import { useCurrentTenant } from '../../../stores/tenant.store';
|
||||
import { useInventoryEnums } from '../../../utils/inventoryEnumHelpers';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { statusColors } from '../../../styles/colors';
|
||||
|
||||
interface AddStockModalProps {
|
||||
@@ -60,8 +60,14 @@ export const AddStockModal: React.FC<AddStockModalProps> = ({
|
||||
});
|
||||
const suppliers = (suppliersData || []).filter(supplier => supplier.status === 'active');
|
||||
|
||||
// Get inventory enum helpers
|
||||
const inventoryEnums = useInventoryEnums();
|
||||
// Get translations
|
||||
const { t } = useTranslation(['inventory', 'common']);
|
||||
|
||||
// Get production stage options using direct i18n
|
||||
const productionStageOptions = Object.values(ProductionStage).map(value => ({
|
||||
value,
|
||||
label: t(`inventory:production_stage.${value}`)
|
||||
}));
|
||||
|
||||
// Create supplier options for select
|
||||
const supplierOptions = [
|
||||
@@ -251,7 +257,7 @@ export const AddStockModal: React.FC<AddStockModalProps> = ({
|
||||
value: formData.production_stage || ProductionStage.RAW_INGREDIENT,
|
||||
type: 'select' as const,
|
||||
editable: true,
|
||||
options: inventoryEnums.getProductionStageOptions()
|
||||
options: productionStageOptions
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -382,7 +388,7 @@ export const AddStockModal: React.FC<AddStockModalProps> = ({
|
||||
];
|
||||
|
||||
return (
|
||||
<StatusModal
|
||||
<EditViewModal
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
mode={mode}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Package, AlertTriangle, Clock, Archive, Thermometer, Plus, Edit, Trash2, CheckCircle, X, Save } from 'lucide-react';
|
||||
import { StatusModal } from '../../ui/StatusModal/StatusModal';
|
||||
import { EditViewModal } from '../../ui/EditViewModal/EditViewModal';
|
||||
import { IngredientResponse, StockResponse, StockUpdate } from '../../../api/types/inventory';
|
||||
import { formatters } from '../../ui/Stats/StatsPresets';
|
||||
import { statusColors } from '../../../styles/colors';
|
||||
@@ -431,7 +431,7 @@ export const BatchModal: React.FC<BatchModalProps> = ({
|
||||
}
|
||||
|
||||
return (
|
||||
<StatusModal
|
||||
<EditViewModal
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
mode="view"
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Plus, Package, Calculator, Settings } from 'lucide-react';
|
||||
import { StatusModal } from '../../ui/StatusModal/StatusModal';
|
||||
import { AddModal } from '../../ui/AddModal/AddModal';
|
||||
import { IngredientCreate, UnitOfMeasure, IngredientCategory, ProductCategory } from '../../../api/types/inventory';
|
||||
import { useInventoryEnums } from '../../../utils/inventoryEnumHelpers';
|
||||
import { statusColors } from '../../../styles/colors';
|
||||
|
||||
interface CreateIngredientModalProps {
|
||||
@@ -21,138 +20,58 @@ export const CreateIngredientModal: React.FC<CreateIngredientModalProps> = ({
|
||||
onClose,
|
||||
onCreateIngredient
|
||||
}) => {
|
||||
const { t } = useTranslation(['inventory']);
|
||||
const [formData, setFormData] = useState<IngredientCreate>({
|
||||
name: '',
|
||||
description: '',
|
||||
category: '',
|
||||
unit_of_measure: 'kg',
|
||||
low_stock_threshold: 10,
|
||||
reorder_point: 20,
|
||||
max_stock_level: 100,
|
||||
is_seasonal: false,
|
||||
average_cost: 0,
|
||||
notes: ''
|
||||
});
|
||||
|
||||
const { t } = useTranslation(['inventory', 'common']);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [mode, setMode] = useState<'overview' | 'edit'>('edit');
|
||||
|
||||
// Get enum options using helpers
|
||||
const inventoryEnums = useInventoryEnums();
|
||||
// Get enum options using direct i18n implementation
|
||||
const ingredientCategoryOptions = Object.values(IngredientCategory).map(value => ({
|
||||
value,
|
||||
label: t(`inventory:ingredient_category.${value}`)
|
||||
})).sort((a, b) => a.label.localeCompare(b.label));
|
||||
|
||||
const productCategoryOptions = Object.values(ProductCategory).map(value => ({
|
||||
value,
|
||||
label: t(`inventory:product_category.${value}`)
|
||||
}));
|
||||
|
||||
// Combine ingredient and product categories
|
||||
const categoryOptions = [
|
||||
...inventoryEnums.getIngredientCategoryOptions(),
|
||||
...inventoryEnums.getProductCategoryOptions()
|
||||
...ingredientCategoryOptions,
|
||||
...productCategoryOptions
|
||||
];
|
||||
|
||||
const unitOptions = inventoryEnums.getUnitOfMeasureOptions();
|
||||
const unitOptions = Object.values(UnitOfMeasure).map(value => ({
|
||||
value,
|
||||
label: t(`inventory:unit_of_measure.${value}`)
|
||||
}));
|
||||
|
||||
const handleFieldChange = (sectionIndex: number, fieldIndex: number, value: string | number | boolean) => {
|
||||
// Map field positions to form data fields
|
||||
const fieldMappings = [
|
||||
// Basic Information section
|
||||
['name', 'description', 'category', 'unit_of_measure'],
|
||||
// Cost and Quantities section
|
||||
['average_cost', 'low_stock_threshold', 'reorder_point', 'max_stock_level'],
|
||||
// Additional Information section
|
||||
['notes']
|
||||
];
|
||||
|
||||
const fieldName = fieldMappings[sectionIndex]?.[fieldIndex] as keyof IngredientCreate;
|
||||
if (fieldName) {
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
[fieldName]: value
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
// Validation
|
||||
if (!formData.name?.trim()) {
|
||||
alert(t('inventory:validation.name_required', 'El nombre es requerido'));
|
||||
return;
|
||||
}
|
||||
|
||||
if (!formData.category) {
|
||||
alert(t('inventory:validation.category_required', 'La categoría es requerida'));
|
||||
return;
|
||||
}
|
||||
|
||||
if (!formData.unit_of_measure) {
|
||||
alert(t('inventory:validation.unit_required', 'La unidad de medida es requerida'));
|
||||
return;
|
||||
}
|
||||
|
||||
if (!formData.low_stock_threshold || formData.low_stock_threshold < 0) {
|
||||
alert(t('inventory:validation.min_greater_than_zero', 'El umbral de stock bajo debe ser un número positivo'));
|
||||
return;
|
||||
}
|
||||
|
||||
if (!formData.reorder_point || formData.reorder_point < 0) {
|
||||
alert(t('inventory:validation.min_greater_than_zero', 'El punto de reorden debe ser un número positivo'));
|
||||
return;
|
||||
}
|
||||
|
||||
if (formData.reorder_point <= formData.low_stock_threshold) {
|
||||
alert(t('inventory:validation.max_greater_than_min', 'El punto de reorden debe ser mayor que el umbral de stock bajo'));
|
||||
return;
|
||||
}
|
||||
const handleSave = async (formData: Record<string, any>) => {
|
||||
// Transform form data to IngredientCreate format
|
||||
const ingredientData: IngredientCreate = {
|
||||
name: formData.name,
|
||||
description: formData.description || '',
|
||||
category: formData.category,
|
||||
unit_of_measure: formData.unit_of_measure,
|
||||
low_stock_threshold: Number(formData.low_stock_threshold),
|
||||
reorder_point: Number(formData.reorder_point),
|
||||
max_stock_level: Number(formData.max_stock_level),
|
||||
is_seasonal: false,
|
||||
average_cost: Number(formData.average_cost) || 0,
|
||||
notes: formData.notes || ''
|
||||
};
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
if (onCreateIngredient) {
|
||||
await onCreateIngredient(formData);
|
||||
await onCreateIngredient(ingredientData);
|
||||
}
|
||||
|
||||
// Reset form
|
||||
setFormData({
|
||||
name: '',
|
||||
description: '',
|
||||
category: '',
|
||||
unit_of_measure: 'kg',
|
||||
low_stock_threshold: 10,
|
||||
reorder_point: 20,
|
||||
max_stock_level: 100,
|
||||
shelf_life_days: undefined,
|
||||
requires_refrigeration: false,
|
||||
requires_freezing: false,
|
||||
is_seasonal: false,
|
||||
average_cost: 0,
|
||||
notes: ''
|
||||
});
|
||||
|
||||
onClose();
|
||||
} catch (error) {
|
||||
console.error('Error creating ingredient:', error);
|
||||
alert('Error al crear el artículo. Por favor, intenta de nuevo.');
|
||||
throw error; // Let AddModal handle error display
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
// Reset form to initial values
|
||||
setFormData({
|
||||
name: '',
|
||||
description: '',
|
||||
category: '',
|
||||
unit_of_measure: 'kg',
|
||||
low_stock_threshold: 10,
|
||||
reorder_point: 20,
|
||||
max_stock_level: 100,
|
||||
shelf_life_days: undefined,
|
||||
requires_refrigeration: false,
|
||||
requires_freezing: false,
|
||||
is_seasonal: false,
|
||||
average_cost: 0,
|
||||
notes: ''
|
||||
});
|
||||
onClose();
|
||||
};
|
||||
|
||||
const statusConfig = {
|
||||
color: statusColors.inProgress.primary,
|
||||
text: t('inventory:actions.add_item', 'Nuevo Artículo'),
|
||||
@@ -168,34 +87,36 @@ export const CreateIngredientModal: React.FC<CreateIngredientModalProps> = ({
|
||||
fields: [
|
||||
{
|
||||
label: t('inventory:fields.name', 'Nombre'),
|
||||
value: formData.name,
|
||||
name: 'name',
|
||||
type: 'text' as const,
|
||||
editable: true,
|
||||
required: true,
|
||||
placeholder: 'Ej: Harina de trigo 000'
|
||||
placeholder: 'Ej: Harina de trigo 000',
|
||||
validation: (value: string | number) => {
|
||||
const str = String(value).trim();
|
||||
return str.length < 2 ? 'El nombre debe tener al menos 2 caracteres' : null;
|
||||
}
|
||||
},
|
||||
{
|
||||
label: t('inventory:fields.description', 'Descripción'),
|
||||
value: formData.description || '',
|
||||
name: 'description',
|
||||
type: 'text' as const,
|
||||
editable: true,
|
||||
placeholder: 'Descripción opcional del artículo'
|
||||
},
|
||||
{
|
||||
label: 'Categoría',
|
||||
value: formData.category,
|
||||
name: 'category',
|
||||
type: 'select' as const,
|
||||
editable: true,
|
||||
required: true,
|
||||
options: categoryOptions
|
||||
options: categoryOptions,
|
||||
placeholder: 'Seleccionar categoría...'
|
||||
},
|
||||
{
|
||||
label: 'Unidad de Medida',
|
||||
value: formData.unit_of_measure,
|
||||
name: 'unit_of_measure',
|
||||
type: 'select' as const,
|
||||
editable: true,
|
||||
required: true,
|
||||
options: unitOptions
|
||||
options: unitOptions,
|
||||
defaultValue: 'kg'
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -205,33 +126,49 @@ export const CreateIngredientModal: React.FC<CreateIngredientModalProps> = ({
|
||||
fields: [
|
||||
{
|
||||
label: 'Costo Promedio',
|
||||
value: formData.average_cost || 0,
|
||||
name: 'average_cost',
|
||||
type: 'currency' as const,
|
||||
editable: true,
|
||||
placeholder: '0.00'
|
||||
placeholder: '0.00',
|
||||
defaultValue: 0,
|
||||
validation: (value: string | number) => {
|
||||
const num = Number(value);
|
||||
return num < 0 ? 'El costo no puede ser negativo' : null;
|
||||
}
|
||||
},
|
||||
{
|
||||
label: 'Umbral Stock Bajo',
|
||||
value: formData.low_stock_threshold,
|
||||
name: 'low_stock_threshold',
|
||||
type: 'number' as const,
|
||||
editable: true,
|
||||
required: true,
|
||||
placeholder: '10'
|
||||
placeholder: '10',
|
||||
defaultValue: 10,
|
||||
validation: (value: string | number) => {
|
||||
const num = Number(value);
|
||||
return num < 0 ? 'El umbral debe ser un número positivo' : null;
|
||||
}
|
||||
},
|
||||
{
|
||||
label: 'Punto de Reorden',
|
||||
value: formData.reorder_point,
|
||||
name: 'reorder_point',
|
||||
type: 'number' as const,
|
||||
editable: true,
|
||||
required: true,
|
||||
placeholder: '20'
|
||||
placeholder: '20',
|
||||
defaultValue: 20,
|
||||
validation: (value: string | number) => {
|
||||
const num = Number(value);
|
||||
return num < 0 ? 'El punto de reorden debe ser un número positivo' : null;
|
||||
}
|
||||
},
|
||||
{
|
||||
label: 'Stock Máximo',
|
||||
value: formData.max_stock_level || 0,
|
||||
name: 'max_stock_level',
|
||||
type: 'number' as const,
|
||||
editable: true,
|
||||
placeholder: '100'
|
||||
placeholder: '100',
|
||||
defaultValue: 100,
|
||||
validation: (value: string | number) => {
|
||||
const num = Number(value);
|
||||
return num < 0 ? 'El stock máximo debe ser un número positivo' : null;
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -241,30 +178,26 @@ export const CreateIngredientModal: React.FC<CreateIngredientModalProps> = ({
|
||||
fields: [
|
||||
{
|
||||
label: 'Notas',
|
||||
value: formData.notes || '',
|
||||
type: 'text' as const,
|
||||
editable: true,
|
||||
placeholder: 'Notas adicionales'
|
||||
name: 'notes',
|
||||
type: 'textarea' as const,
|
||||
placeholder: 'Notas adicionales',
|
||||
span: 2 // Full width
|
||||
}
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
return (
|
||||
<StatusModal
|
||||
<AddModal
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
mode={mode}
|
||||
title="Crear Nuevo Artículo"
|
||||
subtitle="Agregar un nuevo artículo al inventario"
|
||||
statusIndicator={statusConfig}
|
||||
sections={sections}
|
||||
size="lg"
|
||||
loading={loading}
|
||||
showDefaultActions={true}
|
||||
onSave={handleSave}
|
||||
onCancel={handleCancel}
|
||||
onFieldChange={handleFieldChange}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Package, AlertTriangle, CheckCircle, Clock, Euro, Edit, Info, Thermometer, Calendar, Tag, Save, X, TrendingUp } from 'lucide-react';
|
||||
import { StatusModal } from '../../ui/StatusModal/StatusModal';
|
||||
import { EditViewModal } from '../../ui/EditViewModal/EditViewModal';
|
||||
import { IngredientResponse } from '../../../api/types/inventory';
|
||||
import { formatters } from '../../ui/Stats/StatsPresets';
|
||||
import { statusColors } from '../../../styles/colors';
|
||||
@@ -284,7 +284,7 @@ export const ShowInfoModal: React.FC<ShowInfoModalProps> = ({
|
||||
};
|
||||
|
||||
return (
|
||||
<StatusModal
|
||||
<EditViewModal
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
mode={isEditing ? "edit" : "view"}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React from 'react';
|
||||
import { Clock, TrendingDown, Package, AlertCircle, RotateCcw, X } from 'lucide-react';
|
||||
import { StatusModal } from '../../ui/StatusModal/StatusModal';
|
||||
import { EditViewModal } from '../../ui/EditViewModal/EditViewModal';
|
||||
import { IngredientResponse, StockMovementResponse } from '../../../api/types/inventory';
|
||||
import { formatters } from '../../ui/Stats/StatsPresets';
|
||||
import { statusColors } from '../../../styles/colors';
|
||||
@@ -214,7 +214,7 @@ export const StockHistoryModal: React.FC<StockHistoryModalProps> = ({
|
||||
];
|
||||
|
||||
return (
|
||||
<StatusModal
|
||||
<EditViewModal
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
mode="view"
|
||||
|
||||
@@ -7,8 +7,8 @@ import {
|
||||
Card,
|
||||
Badge
|
||||
} from '../../ui';
|
||||
import { StatusModal } from '../../ui/StatusModal';
|
||||
import type { StatusModalSection } from '../../ui/StatusModal';
|
||||
import { EditViewModal } from '../../ui/EditViewModal';
|
||||
import type { StatusModalSection } from '../../ui/EditViewModal';
|
||||
import {
|
||||
OrderCreate,
|
||||
OrderItemCreate,
|
||||
@@ -29,7 +29,7 @@ import { useIngredients } from '../../../api/hooks/inventory';
|
||||
import { ProductType, ProductCategory } from '../../../api/types/inventory';
|
||||
import { useCurrentTenant } from '../../../stores/tenant.store';
|
||||
import { useAuthUser } from '../../../stores/auth.store';
|
||||
import { useOrderEnums } from '../../../utils/enumHelpers';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
interface OrderFormModalProps {
|
||||
isOpen: boolean;
|
||||
@@ -46,7 +46,28 @@ export const OrderFormModal: React.FC<OrderFormModalProps> = ({
|
||||
const currentTenant = useCurrentTenant();
|
||||
const user = useAuthUser();
|
||||
const tenantId = currentTenant?.id || user?.tenant_id || '';
|
||||
const orderEnums = useOrderEnums();
|
||||
const { t } = useTranslation(['orders', 'common']);
|
||||
|
||||
// Create enum options using direct i18n
|
||||
const orderTypeOptions = Object.values(OrderType).map(value => ({
|
||||
value,
|
||||
label: t(`orders:order_types.${value}`)
|
||||
}));
|
||||
|
||||
const priorityLevelOptions = Object.values(PriorityLevel).map(value => ({
|
||||
value,
|
||||
label: t(`orders:priority_levels.${value}`)
|
||||
}));
|
||||
|
||||
const deliveryMethodOptions = Object.values(DeliveryMethod).map(value => ({
|
||||
value,
|
||||
label: t(`orders:delivery_methods.${value}`)
|
||||
}));
|
||||
|
||||
const customerTypeOptions = Object.values(CustomerType).map(value => ({
|
||||
value,
|
||||
label: t(`orders:customer_types.${value}`)
|
||||
}));
|
||||
|
||||
// Form state
|
||||
const [selectedCustomer, setSelectedCustomer] = useState<CustomerResponse | null>(null);
|
||||
@@ -265,21 +286,21 @@ export const OrderFormModal: React.FC<OrderFormModalProps> = ({
|
||||
value: orderData.order_type || OrderType.STANDARD,
|
||||
type: 'select',
|
||||
editable: true,
|
||||
options: orderEnums.getOrderTypeOptions()
|
||||
options: orderTypeOptions
|
||||
},
|
||||
{
|
||||
label: 'Prioridad',
|
||||
value: orderData.priority || PriorityLevel.NORMAL,
|
||||
type: 'select',
|
||||
editable: true,
|
||||
options: orderEnums.getPriorityLevelOptions()
|
||||
options: priorityLevelOptions
|
||||
},
|
||||
{
|
||||
label: 'Método de Entrega',
|
||||
value: orderData.delivery_method || DeliveryMethod.PICKUP,
|
||||
type: 'select',
|
||||
editable: true,
|
||||
options: orderEnums.getDeliveryMethodOptions()
|
||||
options: deliveryMethodOptions
|
||||
},
|
||||
{
|
||||
label: 'Fecha de Entrega',
|
||||
@@ -442,7 +463,7 @@ export const OrderFormModal: React.FC<OrderFormModalProps> = ({
|
||||
|
||||
return (
|
||||
<>
|
||||
<StatusModal
|
||||
<EditViewModal
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
mode="edit"
|
||||
@@ -474,7 +495,7 @@ export const OrderFormModal: React.FC<OrderFormModalProps> = ({
|
||||
|
||||
|
||||
{/* New Customer Modal - Using StatusModal for consistency */}
|
||||
<StatusModal
|
||||
<EditViewModal
|
||||
isOpen={showCustomerForm}
|
||||
onClose={() => setShowCustomerForm(false)}
|
||||
mode="edit"
|
||||
@@ -515,14 +536,14 @@ export const OrderFormModal: React.FC<OrderFormModalProps> = ({
|
||||
value: newCustomerData.customer_type || CustomerType.INDIVIDUAL,
|
||||
type: 'select',
|
||||
editable: true,
|
||||
options: orderEnums.getCustomerTypeOptions()
|
||||
options: customerTypeOptions
|
||||
},
|
||||
{
|
||||
label: 'Método de Entrega Preferido',
|
||||
value: newCustomerData.preferred_delivery_method || DeliveryMethod.PICKUP,
|
||||
type: 'select',
|
||||
editable: true,
|
||||
options: orderEnums.getDeliveryMethodOptions()
|
||||
options: deliveryMethodOptions
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -574,7 +595,7 @@ export const OrderFormModal: React.FC<OrderFormModalProps> = ({
|
||||
/>
|
||||
|
||||
{/* Customer Selector Modal */}
|
||||
<StatusModal
|
||||
<EditViewModal
|
||||
isOpen={showCustomerSelector}
|
||||
onClose={() => setShowCustomerSelector(false)}
|
||||
mode="view"
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
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 React, { useState, useEffect, useMemo } from 'react';
|
||||
import { Plus, Package, Calendar, Building2 } from 'lucide-react';
|
||||
import { AddModal } from '../../ui/AddModal/AddModal';
|
||||
import { useSuppliers } from '../../../api/hooks/suppliers';
|
||||
import { useCreatePurchaseOrder } from '../../../api/hooks/suppliers';
|
||||
import { useIngredients } from '../../../api/hooks/inventory';
|
||||
import { useTenantStore } from '../../../stores/tenant.store';
|
||||
import type { ProcurementRequirementResponse, PurchaseOrderItem } from '../../../api/types/orders';
|
||||
import type { SupplierSummary } from '../../../api/types/suppliers';
|
||||
import type { IngredientResponse } from '../../../api/types/inventory';
|
||||
import { statusColors } from '../../../styles/colors';
|
||||
|
||||
interface CreatePurchaseOrderModalProps {
|
||||
isOpen: boolean;
|
||||
@@ -16,6 +17,7 @@ interface CreatePurchaseOrderModalProps {
|
||||
onSuccess?: () => void;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* CreatePurchaseOrderModal - Modal for creating purchase orders from procurement requirements
|
||||
* Allows supplier selection and purchase order creation for ingredients
|
||||
@@ -27,29 +29,8 @@ export const CreatePurchaseOrderModal: React.FC<CreatePurchaseOrderModalProps> =
|
||||
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: ''
|
||||
});
|
||||
const [selectedSupplier, setSelectedSupplier] = useState<string>('');
|
||||
|
||||
// Get current tenant
|
||||
const { currentTenant } = useTenantStore();
|
||||
@@ -63,657 +44,312 @@ export const CreatePurchaseOrderModal: React.FC<CreatePurchaseOrderModalProps> =
|
||||
);
|
||||
const suppliers = (suppliersData || []).filter(supplier => supplier.status === 'active');
|
||||
|
||||
// Fetch ingredients filtered by selected supplier (only when manually adding products)
|
||||
const { data: ingredientsData = [] } = useIngredients(
|
||||
tenantId,
|
||||
selectedSupplier ? { supplier_id: selectedSupplier } : {},
|
||||
{ enabled: !!tenantId && isOpen && !requirements?.length && !!selectedSupplier }
|
||||
);
|
||||
|
||||
// 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);
|
||||
const supplierOptions = useMemo(() => suppliers.map(supplier => ({
|
||||
value: supplier.id,
|
||||
label: `${supplier.name} (${supplier.supplier_code})`
|
||||
})), [suppliers]);
|
||||
|
||||
// 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]);
|
||||
// Create ingredient options from supplier-filtered ingredients
|
||||
const ingredientOptions = useMemo(() => ingredientsData.map(ingredient => ({
|
||||
value: ingredient.id,
|
||||
label: ingredient.name,
|
||||
data: ingredient // Store full ingredient data for later use
|
||||
})), [ingredientsData]);
|
||||
|
||||
// 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
|
||||
}));
|
||||
}
|
||||
// Unit options for select field
|
||||
const unitOptions = [
|
||||
{ value: 'kg', label: 'Kilogramos' },
|
||||
{ value: 'g', label: 'Gramos' },
|
||||
{ value: 'l', label: 'Litros' },
|
||||
{ value: 'ml', label: 'Mililitros' },
|
||||
{ value: 'units', label: 'Unidades' },
|
||||
{ value: 'boxes', label: 'Cajas' },
|
||||
{ value: 'bags', label: 'Bolsas' }
|
||||
];
|
||||
|
||||
const handleSave = async (formData: Record<string, any>) => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
// Update selectedSupplier if it changed
|
||||
if (formData.supplier_id && formData.supplier_id !== selectedSupplier) {
|
||||
setSelectedSupplier(formData.supplier_id);
|
||||
}
|
||||
|
||||
try {
|
||||
let items: PurchaseOrderItem[] = [];
|
||||
|
||||
if (requirements && requirements.length > 0) {
|
||||
// Create items from requirements list
|
||||
const requiredIngredients = formData.required_ingredients || [];
|
||||
|
||||
if (requiredIngredients.length === 0) {
|
||||
throw new Error('Por favor, selecciona al menos un ingrediente');
|
||||
}
|
||||
|
||||
// Validate quantities
|
||||
const invalidQuantities = requiredIngredients.some((item: any) => item.quantity <= 0);
|
||||
if (invalidQuantities) {
|
||||
throw new Error('Todas las cantidades deben ser mayores a 0');
|
||||
}
|
||||
|
||||
// Prepare purchase order items from requirements
|
||||
items = requiredIngredients.map((item: any) => {
|
||||
// Find original requirement to get product_id
|
||||
const originalReq = requirements.find(req => req.id === item.id);
|
||||
return {
|
||||
inventory_product_id: originalReq?.product_id || '',
|
||||
product_code: item.product_sku || '',
|
||||
product_name: item.product_name,
|
||||
ordered_quantity: item.quantity,
|
||||
unit_of_measure: item.unit_of_measure,
|
||||
unit_price: item.unit_price,
|
||||
quality_requirements: originalReq?.quality_specifications ? JSON.stringify(originalReq.quality_specifications) : undefined,
|
||||
notes: originalReq?.special_requirements || undefined
|
||||
};
|
||||
});
|
||||
} else {
|
||||
// Create items from manual entries
|
||||
const manualProducts = formData.manual_products || [];
|
||||
|
||||
if (manualProducts.length === 0) {
|
||||
throw new Error('Por favor, agrega al menos un producto');
|
||||
}
|
||||
|
||||
// Validate quantities for manual items
|
||||
const invalidQuantities = manualProducts.some((item: any) => item.quantity <= 0);
|
||||
if (invalidQuantities) {
|
||||
throw new Error('Todas las cantidades deben ser mayores a 0');
|
||||
}
|
||||
|
||||
// Validate required fields
|
||||
const invalidProducts = manualProducts.some((item: any) => !item.ingredient_id);
|
||||
if (invalidProducts) {
|
||||
throw new Error('Todos los productos deben tener un ingrediente seleccionado');
|
||||
}
|
||||
|
||||
// Prepare purchase order items from manual entries with ingredient data
|
||||
items = manualProducts.map((item: any) => {
|
||||
// Find the selected ingredient data
|
||||
const selectedIngredient = ingredientsData.find(ing => ing.id === item.ingredient_id);
|
||||
|
||||
return {
|
||||
inventory_product_id: item.ingredient_id,
|
||||
product_code: selectedIngredient?.sku || '',
|
||||
product_name: selectedIngredient?.name || 'Ingrediente desconocido',
|
||||
ordered_quantity: item.quantity,
|
||||
unit_of_measure: item.unit_of_measure,
|
||||
unit_price: item.unit_price,
|
||||
quality_requirements: undefined,
|
||||
notes: undefined
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
// Create purchase order
|
||||
await createPurchaseOrderMutation.mutateAsync({
|
||||
supplier_id: selectedSupplierId,
|
||||
supplier_id: formData.supplier_id,
|
||||
priority: 'normal',
|
||||
required_delivery_date: deliveryDate || undefined,
|
||||
notes: notes || undefined,
|
||||
required_delivery_date: formData.delivery_date || undefined,
|
||||
notes: formData.notes || undefined,
|
||||
items
|
||||
});
|
||||
|
||||
// Close modal and trigger success callback
|
||||
onClose();
|
||||
// Purchase order created successfully
|
||||
|
||||
// Trigger success callback
|
||||
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.');
|
||||
} catch (error) {
|
||||
console.error('Error creating purchase order:', error);
|
||||
throw error; // Let AddModal handle error display
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Log suppliers when they change for debugging
|
||||
useEffect(() => {
|
||||
// console.log('Suppliers updated:', suppliers);
|
||||
}, [suppliers]);
|
||||
const statusConfig = {
|
||||
color: statusColors.inProgress.primary,
|
||||
text: 'Nueva Orden',
|
||||
icon: Plus,
|
||||
isCritical: false,
|
||||
isHighlight: true
|
||||
};
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
const sections = [
|
||||
{
|
||||
title: 'Información del Proveedor',
|
||||
icon: Building2,
|
||||
fields: [
|
||||
{
|
||||
label: 'Proveedor',
|
||||
name: 'supplier_id',
|
||||
type: 'select' as const,
|
||||
required: true,
|
||||
options: supplierOptions,
|
||||
placeholder: 'Seleccionar proveedor...',
|
||||
span: 2
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
title: 'Detalles de la Orden',
|
||||
icon: Calendar,
|
||||
fields: [
|
||||
{
|
||||
label: 'Fecha de Entrega Requerida',
|
||||
name: 'delivery_date',
|
||||
type: 'date' as const,
|
||||
helpText: 'Fecha límite para la entrega (opcional)'
|
||||
},
|
||||
{
|
||||
label: 'Notas',
|
||||
name: 'notes',
|
||||
type: 'textarea' as const,
|
||||
placeholder: 'Instrucciones especiales para el proveedor...',
|
||||
span: 2,
|
||||
helpText: 'Información adicional o instrucciones especiales'
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
title: requirements && requirements.length > 0 ? 'Ingredientes Requeridos' : 'Productos a Comprar',
|
||||
icon: Package,
|
||||
fields: [
|
||||
requirements && requirements.length > 0 ? {
|
||||
label: 'Ingredientes Requeridos',
|
||||
name: 'required_ingredients',
|
||||
type: 'list' as const,
|
||||
span: 2,
|
||||
defaultValue: requirements.map(req => ({
|
||||
id: req.id,
|
||||
product_name: req.product_name,
|
||||
product_sku: req.product_sku || '',
|
||||
quantity: req.approved_quantity || req.net_requirement || req.required_quantity,
|
||||
unit_of_measure: req.unit_of_measure,
|
||||
unit_price: req.estimated_unit_cost || 0,
|
||||
selected: true
|
||||
})),
|
||||
listConfig: {
|
||||
itemFields: [
|
||||
{
|
||||
name: 'product_name',
|
||||
label: 'Producto',
|
||||
type: 'text',
|
||||
required: false // Read-only display
|
||||
},
|
||||
{
|
||||
name: 'product_sku',
|
||||
label: 'SKU',
|
||||
type: 'text',
|
||||
required: false
|
||||
},
|
||||
{
|
||||
name: 'quantity',
|
||||
label: 'Cantidad Requerida',
|
||||
type: 'number',
|
||||
required: true
|
||||
},
|
||||
{
|
||||
name: 'unit_of_measure',
|
||||
label: 'Unidad',
|
||||
type: 'text',
|
||||
required: false
|
||||
},
|
||||
{
|
||||
name: 'unit_price',
|
||||
label: 'Precio Est. (€)',
|
||||
type: 'currency',
|
||||
required: true
|
||||
}
|
||||
],
|
||||
addButtonLabel: 'Agregar Ingrediente',
|
||||
emptyStateText: 'No hay ingredientes requeridos',
|
||||
showSubtotals: true,
|
||||
subtotalFields: { quantity: 'quantity', price: 'unit_price' }
|
||||
},
|
||||
helpText: 'Revisa y ajusta las cantidades y precios de los ingredientes requeridos'
|
||||
} : {
|
||||
label: 'Productos a Comprar',
|
||||
name: 'manual_products',
|
||||
type: 'list' as const,
|
||||
span: 2,
|
||||
defaultValue: [],
|
||||
listConfig: {
|
||||
itemFields: [
|
||||
{
|
||||
name: 'ingredient_id',
|
||||
label: 'Ingrediente',
|
||||
type: 'select',
|
||||
required: true,
|
||||
options: ingredientOptions,
|
||||
placeholder: 'Seleccionar ingrediente...',
|
||||
disabled: false
|
||||
},
|
||||
{
|
||||
name: 'quantity',
|
||||
label: 'Cantidad',
|
||||
type: 'number',
|
||||
required: true,
|
||||
defaultValue: 1
|
||||
},
|
||||
{
|
||||
name: 'unit_of_measure',
|
||||
label: 'Unidad',
|
||||
type: 'select',
|
||||
required: true,
|
||||
defaultValue: 'kg',
|
||||
options: unitOptions
|
||||
},
|
||||
{
|
||||
name: 'unit_price',
|
||||
label: 'Precio Unitario (€)',
|
||||
type: 'currency',
|
||||
required: true,
|
||||
defaultValue: 0,
|
||||
placeholder: '0.00'
|
||||
}
|
||||
],
|
||||
addButtonLabel: 'Agregar Ingrediente',
|
||||
emptyStateText: 'No hay ingredientes disponibles para este proveedor',
|
||||
showSubtotals: true,
|
||||
subtotalFields: { quantity: 'quantity', price: 'unit_price' },
|
||||
disabled: !selectedSupplier
|
||||
},
|
||||
helpText: 'Selecciona ingredientes disponibles del proveedor seleccionado'
|
||||
}
|
||||
]
|
||||
},
|
||||
];
|
||||
|
||||
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>
|
||||
<>
|
||||
<AddModal
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
title="Crear Orden de Compra"
|
||||
subtitle={requirements && requirements.length > 0
|
||||
? "Generar orden de compra desde requerimientos de procuración"
|
||||
: "Crear orden de compra manual"}
|
||||
statusIndicator={statusConfig}
|
||||
sections={sections}
|
||||
size="xl"
|
||||
loading={loading}
|
||||
onSave={handleSave}
|
||||
/>
|
||||
|
||||
{/* 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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Package, Clock, Users, AlertCircle, ChefHat } from 'lucide-react';
|
||||
import { StatusModal, StatusModalSection, StatusModalField } from '../../ui/StatusModal/StatusModal';
|
||||
import { Button, Input, Select } from '../../ui';
|
||||
import React, { useState, useEffect, useMemo } from 'react';
|
||||
import { Package, Clock, Users, AlertCircle, Plus } from 'lucide-react';
|
||||
import { AddModal } from '../../ui/AddModal/AddModal';
|
||||
import {
|
||||
ProductionBatchCreate,
|
||||
ProductionPriorityEnum
|
||||
} from '../../../api/types/production';
|
||||
import { useProductionEnums } from '../../../utils/enumHelpers';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useRecipes } from '../../../api/hooks/recipes';
|
||||
import { useIngredients } from '../../../api/hooks/inventory';
|
||||
import { useCurrentTenant } from '../../../stores/tenant.store';
|
||||
import { statusColors } from '../../../styles/colors';
|
||||
|
||||
interface CreateProductionBatchModalProps {
|
||||
isOpen: boolean;
|
||||
@@ -26,164 +26,127 @@ export const CreateProductionBatchModal: React.FC<CreateProductionBatchModalProp
|
||||
onClose,
|
||||
onCreateBatch
|
||||
}) => {
|
||||
const productionEnums = useProductionEnums();
|
||||
const { t } = useTranslation(['production', 'common']);
|
||||
const currentTenant = useCurrentTenant();
|
||||
const tenantId = currentTenant?.id || '';
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
// API Data
|
||||
const { data: recipes = [], isLoading: recipesLoading } = useRecipes(tenantId);
|
||||
const { data: ingredients = [], isLoading: ingredientsLoading } = useIngredients(tenantId);
|
||||
|
||||
const [formData, setFormData] = useState<ProductionBatchCreate>({
|
||||
product_id: '',
|
||||
product_name: '',
|
||||
recipe_id: '',
|
||||
planned_start_time: '',
|
||||
planned_end_time: '',
|
||||
planned_quantity: 1,
|
||||
planned_duration_minutes: 60,
|
||||
priority: ProductionPriorityEnum.MEDIUM,
|
||||
is_rush_order: false,
|
||||
is_special_recipe: false,
|
||||
production_notes: '',
|
||||
batch_number: '',
|
||||
order_id: '',
|
||||
forecast_id: '',
|
||||
equipment_used: [],
|
||||
staff_assigned: [],
|
||||
station_id: ''
|
||||
});
|
||||
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
// Reset form when modal opens
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
setFormData({
|
||||
product_id: '',
|
||||
product_name: '',
|
||||
recipe_id: '',
|
||||
planned_start_time: '',
|
||||
planned_end_time: '',
|
||||
planned_quantity: 1,
|
||||
planned_duration_minutes: 60,
|
||||
priority: ProductionPriorityEnum.MEDIUM,
|
||||
is_rush_order: false,
|
||||
is_special_recipe: false,
|
||||
production_notes: '',
|
||||
batch_number: '',
|
||||
order_id: '',
|
||||
forecast_id: '',
|
||||
equipment_used: [],
|
||||
staff_assigned: [],
|
||||
station_id: ''
|
||||
});
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
// Filter finished products (ingredients that are finished products)
|
||||
const finishedProducts = ingredients.filter(ing =>
|
||||
const finishedProducts = useMemo(() => ingredients.filter(ing =>
|
||||
ing.type === 'finished_product' ||
|
||||
ing.category === 'finished_products' ||
|
||||
ing.name.toLowerCase().includes('pan') ||
|
||||
ing.name.toLowerCase().includes('pastel') ||
|
||||
ing.name.toLowerCase().includes('torta')
|
||||
);
|
||||
), [ingredients]);
|
||||
|
||||
const productOptions = finishedProducts.map(product => ({
|
||||
const productOptions = useMemo(() => finishedProducts.map(product => ({
|
||||
value: product.id,
|
||||
label: product.name
|
||||
}));
|
||||
})), [finishedProducts]);
|
||||
|
||||
const recipeOptions = recipes.map(recipe => ({
|
||||
const recipeOptions = useMemo(() => recipes.map(recipe => ({
|
||||
value: recipe.id,
|
||||
label: recipe.name
|
||||
}));
|
||||
|
||||
const handleSubmit = async () => {
|
||||
// Validate required fields
|
||||
if (!formData.product_name.trim()) {
|
||||
alert('El nombre del producto es obligatorio');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!formData.product_id.trim()) {
|
||||
alert('El ID del producto es obligatorio');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!formData.planned_start_time) {
|
||||
alert('La fecha de inicio planificada es obligatoria');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!formData.planned_end_time) {
|
||||
alert('La fecha de fin planificada es obligatoria');
|
||||
return;
|
||||
}
|
||||
|
||||
if (formData.planned_quantity <= 0) {
|
||||
alert('La cantidad planificada debe ser mayor a 0');
|
||||
return;
|
||||
}
|
||||
|
||||
if (formData.planned_duration_minutes <= 0) {
|
||||
alert('La duración planificada debe ser mayor a 0');
|
||||
return;
|
||||
}
|
||||
})), [recipes]);
|
||||
|
||||
const handleSave = async (formData: Record<string, any>) => {
|
||||
// Validate that end time is after start time
|
||||
const startTime = new Date(formData.planned_start_time);
|
||||
const endTime = new Date(formData.planned_end_time);
|
||||
|
||||
if (endTime <= startTime) {
|
||||
alert('La fecha de fin debe ser posterior a la fecha de inicio');
|
||||
return;
|
||||
throw new Error('La fecha de fin debe ser posterior a la fecha de inicio');
|
||||
}
|
||||
|
||||
setIsSubmitting(true);
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
// Find the selected product to get the name
|
||||
const selectedProduct = finishedProducts.find(p => p.id === formData.product_id);
|
||||
|
||||
// Convert staff_assigned from string to array
|
||||
const staffArray = formData.staff_assigned
|
||||
? formData.staff_assigned.split(',').map((s: string) => s.trim()).filter((s: string) => s.length > 0)
|
||||
: [];
|
||||
|
||||
const batchData: ProductionBatchCreate = {
|
||||
product_id: formData.product_id,
|
||||
product_name: selectedProduct?.name || '',
|
||||
recipe_id: formData.recipe_id || '',
|
||||
planned_start_time: formData.planned_start_time,
|
||||
planned_end_time: formData.planned_end_time,
|
||||
planned_quantity: Number(formData.planned_quantity),
|
||||
planned_duration_minutes: Number(formData.planned_duration_minutes),
|
||||
priority: formData.priority as ProductionPriorityEnum,
|
||||
is_rush_order: formData.is_rush_order || false,
|
||||
is_special_recipe: formData.is_special_recipe || false,
|
||||
production_notes: formData.production_notes || '',
|
||||
batch_number: formData.batch_number || '',
|
||||
order_id: formData.order_id || '',
|
||||
forecast_id: formData.forecast_id || '',
|
||||
equipment_used: [], // TODO: Add equipment selection if needed
|
||||
staff_assigned: staffArray,
|
||||
station_id: formData.station_id || ''
|
||||
};
|
||||
|
||||
if (onCreateBatch) {
|
||||
await onCreateBatch(formData);
|
||||
await onCreateBatch(batchData);
|
||||
}
|
||||
onClose();
|
||||
} catch (error) {
|
||||
console.error('Error creating production batch:', error);
|
||||
alert('Error al crear el lote de producción. Por favor, inténtalo de nuevo.');
|
||||
throw error; // Let AddModal handle error display
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Define sections for StatusModal
|
||||
const sections: StatusModalSection[] = [
|
||||
const statusConfig = useMemo(() => ({
|
||||
color: statusColors.inProgress.primary,
|
||||
text: 'Nueva Orden',
|
||||
icon: Plus,
|
||||
isCritical: false,
|
||||
isHighlight: true
|
||||
}), []);
|
||||
|
||||
// Auto-calculate end time based on start time and duration
|
||||
const calculateEndTime = (startTime: string, durationMinutes: number): string => {
|
||||
if (!startTime || !durationMinutes) return '';
|
||||
const start = new Date(startTime);
|
||||
const end = new Date(start.getTime() + durationMinutes * 60000);
|
||||
return end.toISOString().slice(0, 16);
|
||||
};
|
||||
|
||||
const sections = useMemo(() => [
|
||||
{
|
||||
title: 'Información del Producto',
|
||||
icon: Package,
|
||||
fields: [
|
||||
{
|
||||
label: 'Producto a Producir *',
|
||||
value: formData.product_id,
|
||||
type: 'select',
|
||||
editable: true,
|
||||
label: 'Producto a Producir',
|
||||
name: 'product_id',
|
||||
type: 'select' as const,
|
||||
required: true,
|
||||
options: productOptions,
|
||||
placeholder: 'Seleccionar producto...',
|
||||
span: 2
|
||||
},
|
||||
{
|
||||
label: 'Receta a Utilizar',
|
||||
value: formData.recipe_id || '',
|
||||
type: 'select',
|
||||
editable: true,
|
||||
name: 'recipe_id',
|
||||
type: 'select' as const,
|
||||
options: recipeOptions,
|
||||
placeholder: 'Seleccionar receta...',
|
||||
span: 2
|
||||
},
|
||||
{
|
||||
label: 'Número de Lote',
|
||||
value: formData.batch_number || '',
|
||||
type: 'text',
|
||||
editable: true,
|
||||
name: 'batch_number',
|
||||
type: 'text' as const,
|
||||
placeholder: 'Se generará automáticamente si se deja vacío'
|
||||
}
|
||||
]
|
||||
@@ -193,32 +156,39 @@ export const CreateProductionBatchModal: React.FC<CreateProductionBatchModalProp
|
||||
icon: Clock,
|
||||
fields: [
|
||||
{
|
||||
label: 'Inicio Planificado *',
|
||||
value: formData.planned_start_time,
|
||||
type: 'datetime-local',
|
||||
editable: true,
|
||||
label: 'Inicio Planificado',
|
||||
name: 'planned_start_time',
|
||||
type: 'date' as const,
|
||||
required: true
|
||||
},
|
||||
{
|
||||
label: 'Fin Planificado *',
|
||||
value: formData.planned_end_time,
|
||||
type: 'datetime-local',
|
||||
editable: true,
|
||||
required: true
|
||||
label: 'Fin Planificado',
|
||||
name: 'planned_end_time',
|
||||
type: 'date' as const,
|
||||
required: true,
|
||||
helpText: 'Se calcula automáticamente basado en inicio y duración'
|
||||
},
|
||||
{
|
||||
label: 'Cantidad Planificada *',
|
||||
value: formData.planned_quantity,
|
||||
type: 'number',
|
||||
editable: true,
|
||||
required: true
|
||||
label: 'Cantidad Planificada',
|
||||
name: 'planned_quantity',
|
||||
type: 'number' as const,
|
||||
required: true,
|
||||
defaultValue: 1,
|
||||
validation: (value: string | number) => {
|
||||
const num = Number(value);
|
||||
return num <= 0 ? 'La cantidad debe ser mayor a 0' : null;
|
||||
}
|
||||
},
|
||||
{
|
||||
label: 'Duración (minutos) *',
|
||||
value: formData.planned_duration_minutes,
|
||||
type: 'number',
|
||||
editable: true,
|
||||
required: true
|
||||
label: 'Duración (minutos)',
|
||||
name: 'planned_duration_minutes',
|
||||
type: 'number' as const,
|
||||
required: true,
|
||||
defaultValue: 60,
|
||||
validation: (value: string | number) => {
|
||||
const num = Number(value);
|
||||
return num <= 0 ? 'La duración debe ser mayor a 0' : null;
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -227,32 +197,35 @@ export const CreateProductionBatchModal: React.FC<CreateProductionBatchModalProp
|
||||
icon: AlertCircle,
|
||||
fields: [
|
||||
{
|
||||
label: 'Prioridad *',
|
||||
value: formData.priority,
|
||||
type: 'select',
|
||||
editable: true,
|
||||
label: 'Prioridad',
|
||||
name: 'priority',
|
||||
type: 'select' as const,
|
||||
required: true,
|
||||
options: productionEnums.getProductionPriorityOptions()
|
||||
defaultValue: ProductionPriorityEnum.MEDIUM,
|
||||
options: Object.values(ProductionPriorityEnum).map(value => ({
|
||||
value,
|
||||
label: t(`production:priority.${value.toLowerCase()}`)
|
||||
}))
|
||||
},
|
||||
{
|
||||
label: 'Orden Urgente',
|
||||
value: formData.is_rush_order ? 'Sí' : 'No',
|
||||
type: 'select',
|
||||
editable: true,
|
||||
name: 'is_rush_order',
|
||||
type: 'select' as const,
|
||||
options: [
|
||||
{ label: 'Sí', value: 'true' },
|
||||
{ label: 'No', value: 'false' }
|
||||
]
|
||||
{ label: 'No', value: false },
|
||||
{ label: 'Sí', value: true }
|
||||
],
|
||||
defaultValue: false
|
||||
},
|
||||
{
|
||||
label: 'Receta Especial',
|
||||
value: formData.is_special_recipe ? 'Sí' : 'No',
|
||||
type: 'select',
|
||||
editable: true,
|
||||
name: 'is_special_recipe',
|
||||
type: 'select' as const,
|
||||
options: [
|
||||
{ label: 'Sí', value: 'true' },
|
||||
{ label: 'No', value: 'false' }
|
||||
]
|
||||
{ label: 'No', value: false },
|
||||
{ label: 'Sí', value: true }
|
||||
],
|
||||
defaultValue: false
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -262,120 +235,34 @@ export const CreateProductionBatchModal: React.FC<CreateProductionBatchModalProp
|
||||
fields: [
|
||||
{
|
||||
label: 'Personal Asignado',
|
||||
value: Array.isArray(formData.staff_assigned) ? formData.staff_assigned.join(', ') : '',
|
||||
type: 'text',
|
||||
editable: true,
|
||||
name: 'staff_assigned',
|
||||
type: 'text' as const,
|
||||
placeholder: 'Separar nombres con comas (opcional)',
|
||||
span: 2
|
||||
span: 2,
|
||||
helpText: 'Ej: Juan Pérez, María García'
|
||||
},
|
||||
{
|
||||
label: 'Notas de Producción',
|
||||
value: formData.production_notes || '',
|
||||
type: 'text',
|
||||
editable: true,
|
||||
name: 'production_notes',
|
||||
type: 'textarea' as const,
|
||||
placeholder: 'Instrucciones especiales, observaciones, etc.',
|
||||
span: 2
|
||||
}
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
// Handle field changes
|
||||
const handleFieldChange = (sectionIndex: number, fieldIndex: number, value: string | number) => {
|
||||
// Map section and field indices to form data properties
|
||||
switch (sectionIndex) {
|
||||
case 0: // Product Information
|
||||
switch (fieldIndex) {
|
||||
case 0: // Product ID
|
||||
const selectedProduct = finishedProducts.find(p => p.id === value);
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
product_id: String(value),
|
||||
product_name: selectedProduct?.name || ''
|
||||
}));
|
||||
break;
|
||||
case 1: // Recipe ID
|
||||
setFormData(prev => ({ ...prev, recipe_id: String(value) }));
|
||||
break;
|
||||
case 2: // Batch Number
|
||||
setFormData(prev => ({ ...prev, batch_number: String(value) }));
|
||||
break;
|
||||
}
|
||||
break;
|
||||
case 1: // Production Planning
|
||||
switch (fieldIndex) {
|
||||
case 0: // Start Time
|
||||
const startTime = String(value);
|
||||
setFormData(prev => ({ ...prev, planned_start_time: startTime }));
|
||||
// Auto-calculate end time
|
||||
if (startTime && formData.planned_duration_minutes > 0) {
|
||||
const start = new Date(startTime);
|
||||
const end = new Date(start.getTime() + formData.planned_duration_minutes * 60000);
|
||||
const endTimeString = end.toISOString().slice(0, 16);
|
||||
setFormData(prev => ({ ...prev, planned_end_time: endTimeString }));
|
||||
}
|
||||
break;
|
||||
case 1: // End Time
|
||||
setFormData(prev => ({ ...prev, planned_end_time: String(value) }));
|
||||
break;
|
||||
case 2: // Quantity
|
||||
setFormData(prev => ({ ...prev, planned_quantity: Number(value) || 1 }));
|
||||
break;
|
||||
case 3: // Duration
|
||||
const duration = Number(value) || 60;
|
||||
setFormData(prev => ({ ...prev, planned_duration_minutes: duration }));
|
||||
// Auto-calculate end time
|
||||
if (formData.planned_start_time && duration > 0) {
|
||||
const start = new Date(formData.planned_start_time);
|
||||
const end = new Date(start.getTime() + duration * 60000);
|
||||
const endTimeString = end.toISOString().slice(0, 16);
|
||||
setFormData(prev => ({ ...prev, planned_end_time: endTimeString }));
|
||||
}
|
||||
break;
|
||||
}
|
||||
break;
|
||||
case 2: // Configuration
|
||||
switch (fieldIndex) {
|
||||
case 0: // Priority
|
||||
setFormData(prev => ({ ...prev, priority: value as ProductionPriorityEnum }));
|
||||
break;
|
||||
case 1: // Rush Order
|
||||
setFormData(prev => ({ ...prev, is_rush_order: String(value) === 'true' }));
|
||||
break;
|
||||
case 2: // Special Recipe
|
||||
setFormData(prev => ({ ...prev, is_special_recipe: String(value) === 'true' }));
|
||||
break;
|
||||
}
|
||||
break;
|
||||
case 3: // Resources and Notes
|
||||
switch (fieldIndex) {
|
||||
case 0: // Staff Assigned
|
||||
const staff = String(value).split(',').map(s => s.trim()).filter(s => s.length > 0);
|
||||
setFormData(prev => ({ ...prev, staff_assigned: staff }));
|
||||
break;
|
||||
case 1: // Production Notes
|
||||
setFormData(prev => ({ ...prev, production_notes: String(value) }));
|
||||
break;
|
||||
}
|
||||
break;
|
||||
}
|
||||
};
|
||||
], [productOptions, recipeOptions, t]);
|
||||
|
||||
return (
|
||||
<StatusModal
|
||||
<AddModal
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
mode="edit"
|
||||
title="Nueva Orden de Producción"
|
||||
subtitle="Crear un nuevo lote de producción"
|
||||
statusIndicator={statusConfig}
|
||||
sections={sections}
|
||||
size="xl"
|
||||
loading={isSubmitting}
|
||||
onFieldChange={handleFieldChange}
|
||||
image={undefined}
|
||||
onSave={handleSubmit}
|
||||
onCancel={onClose}
|
||||
showDefaultActions={true}
|
||||
loading={loading}
|
||||
onSave={handleSave}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -3,7 +3,7 @@ import { Clock, Timer, CheckCircle, AlertCircle, Package, Play, Pause, X, Edit,
|
||||
import { StatusCard, StatusIndicatorConfig } from '../../ui/StatusCard/StatusCard';
|
||||
import { statusColors } from '../../../styles/colors';
|
||||
import { ProductionBatchResponse, ProductionStatus, ProductionPriority } from '../../../api/types/production';
|
||||
import { useProductionEnums } from '../../../utils/enumHelpers';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
export interface ProductionStatusCardProps {
|
||||
batch: ProductionBatchResponse;
|
||||
@@ -32,7 +32,7 @@ export const ProductionStatusCard: React.FC<ProductionStatusCardProps> = ({
|
||||
showDetailedProgress = true,
|
||||
compact = false
|
||||
}) => {
|
||||
const productionEnums = useProductionEnums();
|
||||
const { t } = useTranslation(['production', 'common']);
|
||||
|
||||
const getProductionStatusConfig = (status: ProductionStatus, priority: ProductionPriority): StatusIndicatorConfig => {
|
||||
const statusConfig = {
|
||||
@@ -76,7 +76,7 @@ export const ProductionStatusCard: React.FC<ProductionStatusCardProps> = ({
|
||||
|
||||
return {
|
||||
color: getStatusColorForProduction(status),
|
||||
text: productionEnums.getProductionStatusLabel(status),
|
||||
text: t(`production:status.${status}`),
|
||||
icon: Icon,
|
||||
isCritical,
|
||||
isHighlight
|
||||
@@ -313,7 +313,7 @@ export const ProductionStatusCard: React.FC<ProductionStatusCardProps> = ({
|
||||
|
||||
return {
|
||||
label: 'Prioridad',
|
||||
value: productionEnums.getProductionPriorityLabel(batch.priority)
|
||||
value: t(`production:priority.${batch.priority}`)
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -26,7 +26,7 @@ import {
|
||||
Modal,
|
||||
StatsGrid
|
||||
} from '../../ui';
|
||||
import { LoadingSpinner } from '../../shared';
|
||||
import { LoadingSpinner } from '../../ui';
|
||||
import { PageHeader } from '../../layout';
|
||||
import { useCurrentTenant } from '../../../stores/tenant.store';
|
||||
import {
|
||||
|
||||
@@ -0,0 +1,362 @@
|
||||
import React, { useRef, useEffect } from 'react';
|
||||
|
||||
export type ChartType = 'line' | 'bar' | 'area' | 'pie' | 'doughnut';
|
||||
|
||||
export interface ChartDataPoint {
|
||||
x: string | number;
|
||||
y: number;
|
||||
label?: string;
|
||||
color?: string;
|
||||
}
|
||||
|
||||
export interface ChartSeries {
|
||||
id: string;
|
||||
name: string;
|
||||
type: ChartType;
|
||||
data: ChartDataPoint[];
|
||||
color: string;
|
||||
visible?: boolean;
|
||||
}
|
||||
|
||||
export interface AnalyticsChartProps {
|
||||
series: ChartSeries[];
|
||||
height?: number;
|
||||
className?: string;
|
||||
showLegend?: boolean;
|
||||
showGrid?: boolean;
|
||||
animate?: boolean;
|
||||
}
|
||||
|
||||
export const AnalyticsChart: React.FC<AnalyticsChartProps> = ({
|
||||
series,
|
||||
height = 300,
|
||||
className = '',
|
||||
showLegend = true,
|
||||
showGrid = true,
|
||||
animate = true
|
||||
}) => {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!canvasRef.current || series.length === 0) return;
|
||||
|
||||
const canvas = canvasRef.current;
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) return;
|
||||
|
||||
// Set canvas size
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
canvas.width = rect.width * dpr;
|
||||
canvas.height = rect.height * dpr;
|
||||
ctx.scale(dpr, dpr);
|
||||
|
||||
// Clear canvas
|
||||
ctx.clearRect(0, 0, rect.width, rect.height);
|
||||
|
||||
// Chart settings
|
||||
const padding = 40;
|
||||
const chartWidth = rect.width - 2 * padding;
|
||||
const chartHeight = rect.height - 2 * padding - (showLegend ? 40 : 0);
|
||||
|
||||
// Get visible series
|
||||
const visibleSeries = series.filter(s => s.visible !== false);
|
||||
if (visibleSeries.length === 0) return;
|
||||
|
||||
// Draw based on chart type
|
||||
const primarySeries = visibleSeries[0];
|
||||
|
||||
if (primarySeries.type === 'pie' || primarySeries.type === 'doughnut') {
|
||||
drawPieChart(ctx, primarySeries, rect.width, rect.height - (showLegend ? 40 : 0));
|
||||
} else {
|
||||
drawCartesianChart(ctx, visibleSeries, padding, chartWidth, chartHeight, showGrid);
|
||||
}
|
||||
|
||||
// Draw legend
|
||||
if (showLegend) {
|
||||
drawLegend(ctx, visibleSeries, rect.width, rect.height);
|
||||
}
|
||||
}, [series, height, showLegend, showGrid]);
|
||||
|
||||
const drawPieChart = (ctx: CanvasRenderingContext2D, series: ChartSeries, width: number, height: number) => {
|
||||
const centerX = width / 2;
|
||||
const centerY = height / 2;
|
||||
const radius = Math.min(width, height) / 3;
|
||||
const innerRadius = series.type === 'doughnut' ? radius * 0.6 : 0;
|
||||
|
||||
const total = series.data.reduce((sum, point) => sum + point.y, 0);
|
||||
let startAngle = -Math.PI / 2;
|
||||
|
||||
series.data.forEach((point, index) => {
|
||||
const sliceAngle = (point.y / total) * 2 * Math.PI;
|
||||
const color = point.color || series.color || getDefaultColor(index);
|
||||
|
||||
// Draw slice
|
||||
ctx.fillStyle = color;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(centerX, centerY);
|
||||
ctx.arc(centerX, centerY, radius, startAngle, startAngle + sliceAngle);
|
||||
if (innerRadius > 0) {
|
||||
ctx.arc(centerX, centerY, innerRadius, startAngle + sliceAngle, startAngle, true);
|
||||
}
|
||||
ctx.closePath();
|
||||
ctx.fill();
|
||||
|
||||
// Draw slice border
|
||||
ctx.strokeStyle = '#ffffff';
|
||||
ctx.lineWidth = 2;
|
||||
ctx.stroke();
|
||||
|
||||
// Draw labels
|
||||
const labelAngle = startAngle + sliceAngle / 2;
|
||||
const labelRadius = radius + 20;
|
||||
const labelX = centerX + Math.cos(labelAngle) * labelRadius;
|
||||
const labelY = centerY + Math.sin(labelAngle) * labelRadius;
|
||||
|
||||
ctx.fillStyle = '#374151';
|
||||
ctx.font = '12px sans-serif';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.fillText(point.label || point.x.toString(), labelX, labelY);
|
||||
|
||||
const percentage = ((point.y / total) * 100).toFixed(1);
|
||||
ctx.fillText(`${percentage}%`, labelX, labelY + 15);
|
||||
|
||||
startAngle += sliceAngle;
|
||||
});
|
||||
|
||||
// Draw center label for doughnut
|
||||
if (series.type === 'doughnut') {
|
||||
ctx.fillStyle = '#374151';
|
||||
ctx.font = 'bold 16px sans-serif';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.fillText(total.toString(), centerX, centerY - 5);
|
||||
ctx.font = '12px sans-serif';
|
||||
ctx.fillText('Total', centerX, centerY + 10);
|
||||
}
|
||||
};
|
||||
|
||||
const drawCartesianChart = (
|
||||
ctx: CanvasRenderingContext2D,
|
||||
seriesList: ChartSeries[],
|
||||
padding: number,
|
||||
chartWidth: number,
|
||||
chartHeight: number,
|
||||
showGrid: boolean
|
||||
) => {
|
||||
// Get all data points to determine scales
|
||||
const allData = seriesList.flatMap(s => s.data);
|
||||
const maxY = Math.max(...allData.map(d => d.y));
|
||||
const minY = Math.min(0, Math.min(...allData.map(d => d.y)));
|
||||
|
||||
// Draw grid
|
||||
if (showGrid) {
|
||||
ctx.strokeStyle = '#e2e8f0';
|
||||
ctx.lineWidth = 1;
|
||||
|
||||
// Horizontal grid lines
|
||||
for (let i = 0; i <= 5; i++) {
|
||||
const y = padding + (i * chartHeight) / 5;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(padding, y);
|
||||
ctx.lineTo(padding + chartWidth, y);
|
||||
ctx.stroke();
|
||||
|
||||
// Y-axis labels
|
||||
const value = maxY - (i * (maxY - minY)) / 5;
|
||||
ctx.fillStyle = '#64748b';
|
||||
ctx.font = '12px sans-serif';
|
||||
ctx.textAlign = 'right';
|
||||
ctx.fillText(Math.round(value).toString(), padding - 10, y + 4);
|
||||
}
|
||||
}
|
||||
|
||||
// Draw axes
|
||||
ctx.strokeStyle = '#374151';
|
||||
ctx.lineWidth = 2;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(padding, padding);
|
||||
ctx.lineTo(padding, padding + chartHeight);
|
||||
ctx.lineTo(padding + chartWidth, padding + chartHeight);
|
||||
ctx.stroke();
|
||||
|
||||
// Draw each series
|
||||
seriesList.forEach((series, seriesIndex) => {
|
||||
ctx.strokeStyle = series.color || getDefaultColor(seriesIndex);
|
||||
ctx.fillStyle = series.color || getDefaultColor(seriesIndex);
|
||||
|
||||
if (series.type === 'line') {
|
||||
drawLineChart(ctx, series, padding, chartWidth, chartHeight, maxY, minY);
|
||||
} else if (series.type === 'bar') {
|
||||
drawBarChart(ctx, series, padding, chartWidth, chartHeight, maxY, minY, seriesIndex, seriesList.length);
|
||||
} else if (series.type === 'area') {
|
||||
drawAreaChart(ctx, series, padding, chartWidth, chartHeight, maxY, minY);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const drawLineChart = (
|
||||
ctx: CanvasRenderingContext2D,
|
||||
series: ChartSeries,
|
||||
padding: number,
|
||||
chartWidth: number,
|
||||
chartHeight: number,
|
||||
maxY: number,
|
||||
minY: number
|
||||
) => {
|
||||
if (series.data.length === 0) return;
|
||||
|
||||
ctx.lineWidth = 3;
|
||||
ctx.beginPath();
|
||||
|
||||
series.data.forEach((point, index) => {
|
||||
const x = padding + (index * chartWidth) / (series.data.length - 1 || 1);
|
||||
const y = padding + chartHeight - ((point.y - minY) / (maxY - minY)) * chartHeight;
|
||||
|
||||
if (index === 0) {
|
||||
ctx.moveTo(x, y);
|
||||
} else {
|
||||
ctx.lineTo(x, y);
|
||||
}
|
||||
|
||||
// Draw points
|
||||
ctx.fillStyle = ctx.strokeStyle;
|
||||
ctx.beginPath();
|
||||
ctx.arc(x, y, 4, 0, 2 * Math.PI);
|
||||
ctx.fill();
|
||||
});
|
||||
|
||||
ctx.stroke();
|
||||
};
|
||||
|
||||
const drawBarChart = (
|
||||
ctx: CanvasRenderingContext2D,
|
||||
series: ChartSeries,
|
||||
padding: number,
|
||||
chartWidth: number,
|
||||
chartHeight: number,
|
||||
maxY: number,
|
||||
minY: number,
|
||||
seriesIndex: number,
|
||||
totalSeries: number
|
||||
) => {
|
||||
const barGroupWidth = chartWidth / series.data.length;
|
||||
const barWidth = barGroupWidth / totalSeries - 4;
|
||||
|
||||
series.data.forEach((point, index) => {
|
||||
const x = padding + index * barGroupWidth + seriesIndex * (barWidth + 2) + 2;
|
||||
const barHeight = ((point.y - minY) / (maxY - minY)) * chartHeight;
|
||||
const y = padding + chartHeight - barHeight;
|
||||
|
||||
ctx.fillRect(x, y, barWidth, barHeight);
|
||||
|
||||
// Draw value labels
|
||||
ctx.fillStyle = '#374151';
|
||||
ctx.font = '12px sans-serif';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.fillText(point.y.toString(), x + barWidth / 2, y - 5);
|
||||
|
||||
// Draw x-axis labels
|
||||
if (seriesIndex === 0) {
|
||||
ctx.fillText(
|
||||
point.label || point.x.toString(),
|
||||
padding + index * barGroupWidth + barGroupWidth / 2,
|
||||
padding + chartHeight + 20
|
||||
);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const drawAreaChart = (
|
||||
ctx: CanvasRenderingContext2D,
|
||||
series: ChartSeries,
|
||||
padding: number,
|
||||
chartWidth: number,
|
||||
chartHeight: number,
|
||||
maxY: number,
|
||||
minY: number
|
||||
) => {
|
||||
if (series.data.length === 0) return;
|
||||
|
||||
// Create gradient
|
||||
const gradient = ctx.createLinearGradient(0, padding, 0, padding + chartHeight);
|
||||
gradient.addColorStop(0, `${series.color}40`);
|
||||
gradient.addColorStop(1, `${series.color}10`);
|
||||
|
||||
ctx.fillStyle = gradient;
|
||||
ctx.strokeStyle = series.color;
|
||||
ctx.lineWidth = 2;
|
||||
|
||||
ctx.beginPath();
|
||||
|
||||
// Start from bottom left
|
||||
ctx.moveTo(padding, padding + chartHeight);
|
||||
|
||||
series.data.forEach((point, index) => {
|
||||
const x = padding + (index * chartWidth) / (series.data.length - 1 || 1);
|
||||
const y = padding + chartHeight - ((point.y - minY) / (maxY - minY)) * chartHeight;
|
||||
ctx.lineTo(x, y);
|
||||
});
|
||||
|
||||
// Close the path at bottom right
|
||||
ctx.lineTo(padding + chartWidth, padding + chartHeight);
|
||||
ctx.closePath();
|
||||
ctx.fill();
|
||||
|
||||
// Draw the line on top
|
||||
ctx.beginPath();
|
||||
series.data.forEach((point, index) => {
|
||||
const x = padding + (index * chartWidth) / (series.data.length - 1 || 1);
|
||||
const y = padding + chartHeight - ((point.y - minY) / (maxY - minY)) * chartHeight;
|
||||
|
||||
if (index === 0) {
|
||||
ctx.moveTo(x, y);
|
||||
} else {
|
||||
ctx.lineTo(x, y);
|
||||
}
|
||||
});
|
||||
ctx.stroke();
|
||||
};
|
||||
|
||||
const drawLegend = (ctx: CanvasRenderingContext2D, seriesList: ChartSeries[], width: number, height: number) => {
|
||||
const legendY = height - 30;
|
||||
let legendX = width / 2 - (seriesList.length * 80) / 2;
|
||||
|
||||
seriesList.forEach((series, index) => {
|
||||
// Draw legend color box
|
||||
ctx.fillStyle = series.color || getDefaultColor(index);
|
||||
ctx.fillRect(legendX, legendY, 12, 12);
|
||||
|
||||
// Draw legend text
|
||||
ctx.fillStyle = '#374151';
|
||||
ctx.font = '12px sans-serif';
|
||||
ctx.textAlign = 'left';
|
||||
ctx.fillText(series.name, legendX + 18, legendY + 9);
|
||||
|
||||
legendX += Math.max(80, ctx.measureText(series.name).width + 30);
|
||||
});
|
||||
};
|
||||
|
||||
const getDefaultColor = (index: number): string => {
|
||||
const colors = [
|
||||
'#d97706', // orange
|
||||
'#0284c7', // blue
|
||||
'#16a34a', // green
|
||||
'#dc2626', // red
|
||||
'#7c3aed', // purple
|
||||
'#0891b2', // cyan
|
||||
'#ea580c', // orange variant
|
||||
'#059669' // emerald
|
||||
];
|
||||
return colors[index % colors.length];
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`relative ${className}`} style={{ height: `${height}px` }}>
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
className="w-full h-full"
|
||||
style={{ height: `${height}px` }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,103 @@
|
||||
import React from 'react';
|
||||
import { Card } from '../../../ui';
|
||||
import { LucideIcon } from 'lucide-react';
|
||||
|
||||
export interface AnalyticsWidgetProps {
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
icon?: LucideIcon;
|
||||
loading?: boolean;
|
||||
error?: string;
|
||||
className?: string;
|
||||
children: React.ReactNode;
|
||||
actions?: React.ReactNode;
|
||||
}
|
||||
|
||||
export const AnalyticsWidget: React.FC<AnalyticsWidgetProps> = ({
|
||||
title,
|
||||
subtitle,
|
||||
icon: Icon,
|
||||
loading = false,
|
||||
error,
|
||||
className = '',
|
||||
children,
|
||||
actions
|
||||
}) => {
|
||||
if (error) {
|
||||
return (
|
||||
<Card className={`p-6 ${className}`}>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center space-x-3">
|
||||
{Icon && (
|
||||
<div className="w-10 h-10 bg-red-100 dark:bg-red-900/20 rounded-lg flex items-center justify-center">
|
||||
<Icon className="w-5 h-5 text-red-600 dark:text-red-400" />
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-[var(--text-primary)]">{title}</h3>
|
||||
{subtitle && <p className="text-sm text-[var(--text-secondary)]">{subtitle}</p>}
|
||||
</div>
|
||||
</div>
|
||||
{actions}
|
||||
</div>
|
||||
<div className="text-center py-8">
|
||||
<div className="w-12 h-12 mx-auto bg-red-100 dark:bg-red-900/20 rounded-lg flex items-center justify-center mb-3">
|
||||
{Icon && <Icon className="w-6 h-6 text-red-600 dark:text-red-400" />}
|
||||
</div>
|
||||
<p className="text-sm text-red-600 dark:text-red-400">Error: {error}</p>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Card className={`p-6 ${className}`}>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center space-x-3">
|
||||
{Icon && (
|
||||
<div className="w-10 h-10 bg-[var(--bg-tertiary)] rounded-lg flex items-center justify-center">
|
||||
<Icon className="w-5 h-5 text-[var(--text-tertiary)]" />
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-[var(--text-primary)]">{title}</h3>
|
||||
{subtitle && <p className="text-sm text-[var(--text-secondary)]">{subtitle}</p>}
|
||||
</div>
|
||||
</div>
|
||||
{actions}
|
||||
</div>
|
||||
<div className="animate-pulse">
|
||||
<div className="h-4 bg-[var(--bg-tertiary)] rounded w-3/4 mb-3"></div>
|
||||
<div className="h-4 bg-[var(--bg-tertiary)] rounded w-1/2 mb-3"></div>
|
||||
<div className="h-8 bg-[var(--bg-tertiary)] rounded w-full mb-4"></div>
|
||||
<div className="space-y-2">
|
||||
<div className="h-4 bg-[var(--bg-tertiary)] rounded"></div>
|
||||
<div className="h-4 bg-[var(--bg-tertiary)] rounded w-5/6"></div>
|
||||
<div className="h-4 bg-[var(--bg-tertiary)] rounded w-4/6"></div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className={`p-6 ${className}`}>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center space-x-3">
|
||||
{Icon && (
|
||||
<div className="w-10 h-10 bg-[var(--color-primary)]/10 rounded-lg flex items-center justify-center">
|
||||
<Icon className="w-5 h-5 text-[var(--color-primary)]" />
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-[var(--text-primary)]">{title}</h3>
|
||||
{subtitle && <p className="text-sm text-[var(--text-secondary)]">{subtitle}</p>}
|
||||
</div>
|
||||
</div>
|
||||
{actions}
|
||||
</div>
|
||||
{children}
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,382 @@
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Brain, TrendingUp, AlertTriangle, Target, Zap, DollarSign, Clock } from 'lucide-react';
|
||||
import { AnalyticsWidget } from '../AnalyticsWidget';
|
||||
import { Badge, Button } from '../../../../ui';
|
||||
import { useCurrentTenant } from '../../../../../stores/tenant.store';
|
||||
|
||||
interface AIInsight {
|
||||
id: string;
|
||||
type: 'optimization' | 'prediction' | 'anomaly' | 'recommendation';
|
||||
priority: 'low' | 'medium' | 'high' | 'critical';
|
||||
confidence: number; // percentage
|
||||
title: string;
|
||||
description: string;
|
||||
impact: {
|
||||
type: 'cost_savings' | 'efficiency_gain' | 'quality_improvement' | 'risk_mitigation';
|
||||
value: number;
|
||||
unit: 'euros' | 'percentage' | 'hours' | 'units';
|
||||
};
|
||||
actionable: boolean;
|
||||
category: 'production' | 'quality' | 'maintenance' | 'energy' | 'scheduling';
|
||||
equipment?: string;
|
||||
timeline: string;
|
||||
status: 'new' | 'acknowledged' | 'in_progress' | 'implemented' | 'dismissed';
|
||||
}
|
||||
|
||||
export const AIInsightsWidget: React.FC = () => {
|
||||
const { t } = useTranslation('production');
|
||||
const currentTenant = useCurrentTenant();
|
||||
|
||||
// Mock AI insights data - replace with real AI API call
|
||||
const aiInsights: AIInsight[] = [
|
||||
{
|
||||
id: '1',
|
||||
type: 'optimization',
|
||||
priority: 'high',
|
||||
confidence: 92,
|
||||
title: 'Optimización de Horarios de Horneado',
|
||||
description: 'Ajustar los horarios de horneado para aprovechar las tarifas eléctricas más bajas puede reducir los costos de energía en un 15%.',
|
||||
impact: {
|
||||
type: 'cost_savings',
|
||||
value: 45,
|
||||
unit: 'euros'
|
||||
},
|
||||
actionable: true,
|
||||
category: 'energy',
|
||||
equipment: 'Horno Principal',
|
||||
timeline: 'Esta semana',
|
||||
status: 'new'
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
type: 'prediction',
|
||||
priority: 'medium',
|
||||
confidence: 87,
|
||||
title: 'Aumento de Demanda de Croissants Predicho',
|
||||
description: 'Los datos meteorológicos y de eventos sugieren un aumento del 40% en la demanda de croissants este fin de semana.',
|
||||
impact: {
|
||||
type: 'efficiency_gain',
|
||||
value: 25,
|
||||
unit: 'percentage'
|
||||
},
|
||||
actionable: true,
|
||||
category: 'production',
|
||||
timeline: 'Este fin de semana',
|
||||
status: 'acknowledged'
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
type: 'anomaly',
|
||||
priority: 'critical',
|
||||
confidence: 96,
|
||||
title: 'Declive en Puntuación de Calidad Detectado',
|
||||
description: 'La calidad del pan ha disminuido un 8% en los últimos 3 días. Verificar los niveles de humedad de la harina.',
|
||||
impact: {
|
||||
type: 'quality_improvement',
|
||||
value: 12,
|
||||
unit: 'percentage'
|
||||
},
|
||||
actionable: true,
|
||||
category: 'quality',
|
||||
timeline: 'Inmediatamente',
|
||||
status: 'in_progress'
|
||||
},
|
||||
{
|
||||
id: '4',
|
||||
type: 'recommendation',
|
||||
priority: 'medium',
|
||||
confidence: 89,
|
||||
title: 'Mantenimiento Preventivo Recomendado',
|
||||
description: 'La Mezcladora #2 muestra patrones de desgaste temprano. Programar mantenimiento para prevenir averías.',
|
||||
impact: {
|
||||
type: 'risk_mitigation',
|
||||
value: 8,
|
||||
unit: 'hours'
|
||||
},
|
||||
actionable: true,
|
||||
category: 'maintenance',
|
||||
equipment: 'Mezcladora A',
|
||||
timeline: 'Próxima semana',
|
||||
status: 'new'
|
||||
},
|
||||
{
|
||||
id: '5',
|
||||
type: 'optimization',
|
||||
priority: 'low',
|
||||
confidence: 78,
|
||||
title: 'Optimización de Secuencia de Productos',
|
||||
description: 'Reordenar la secuencia de productos puede mejorar la eficiencia general en un 5%.',
|
||||
impact: {
|
||||
type: 'efficiency_gain',
|
||||
value: 5,
|
||||
unit: 'percentage'
|
||||
},
|
||||
actionable: true,
|
||||
category: 'scheduling',
|
||||
timeline: 'Próximo mes',
|
||||
status: 'dismissed'
|
||||
}
|
||||
];
|
||||
|
||||
const getTypeIcon = (type: AIInsight['type']) => {
|
||||
switch (type) {
|
||||
case 'optimization': return Target;
|
||||
case 'prediction': return TrendingUp;
|
||||
case 'anomaly': return AlertTriangle;
|
||||
case 'recommendation': return Brain;
|
||||
default: return Brain;
|
||||
}
|
||||
};
|
||||
|
||||
const getTypeColor = (type: AIInsight['type']) => {
|
||||
switch (type) {
|
||||
case 'optimization': return 'text-green-600';
|
||||
case 'prediction': return 'text-blue-600';
|
||||
case 'anomaly': return 'text-red-600';
|
||||
case 'recommendation': return 'text-purple-600';
|
||||
default: return 'text-gray-600';
|
||||
}
|
||||
};
|
||||
|
||||
const getPriorityBadgeVariant = (priority: AIInsight['priority']) => {
|
||||
switch (priority) {
|
||||
case 'critical': return 'error';
|
||||
case 'high': return 'warning';
|
||||
case 'medium': return 'info';
|
||||
case 'low': return 'default';
|
||||
default: return 'default';
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusBadgeVariant = (status: AIInsight['status']) => {
|
||||
switch (status) {
|
||||
case 'new': return 'warning';
|
||||
case 'acknowledged': return 'info';
|
||||
case 'in_progress': return 'info';
|
||||
case 'implemented': return 'success';
|
||||
case 'dismissed': return 'default';
|
||||
default: return 'default';
|
||||
}
|
||||
};
|
||||
|
||||
const getImpactIcon = (type: AIInsight['impact']['type']) => {
|
||||
switch (type) {
|
||||
case 'cost_savings': return DollarSign;
|
||||
case 'efficiency_gain': return Zap;
|
||||
case 'quality_improvement': return Target;
|
||||
case 'risk_mitigation': return Clock;
|
||||
default: return Target;
|
||||
}
|
||||
};
|
||||
|
||||
const formatImpactValue = (impact: AIInsight['impact']) => {
|
||||
switch (impact.unit) {
|
||||
case 'euros': return `€${impact.value}`;
|
||||
case 'percentage': return `${impact.value}%`;
|
||||
case 'hours': return `${impact.value}h`;
|
||||
case 'units': return `${impact.value} unidades`;
|
||||
default: return impact.value.toString();
|
||||
}
|
||||
};
|
||||
|
||||
const activeInsights = aiInsights.filter(i => i.status !== 'dismissed');
|
||||
const highPriorityInsights = activeInsights.filter(i => i.priority === 'high' || i.priority === 'critical');
|
||||
const implementedInsights = aiInsights.filter(i => i.status === 'implemented');
|
||||
const avgConfidence = activeInsights.reduce((sum, insight) => sum + insight.confidence, 0) / activeInsights.length;
|
||||
|
||||
const totalPotentialSavings = aiInsights
|
||||
.filter(i => i.impact.type === 'cost_savings' && i.status !== 'dismissed')
|
||||
.reduce((sum, insight) => sum + insight.impact.value, 0);
|
||||
|
||||
return (
|
||||
<AnalyticsWidget
|
||||
title={t('ai.title')}
|
||||
subtitle={t('ai.subtitle')}
|
||||
icon={Brain}
|
||||
actions={
|
||||
<div className="flex space-x-2">
|
||||
<Button variant="outline" size="sm">
|
||||
<Brain className="w-4 h-4 mr-1" />
|
||||
{t('ai.actions.train_model')}
|
||||
</Button>
|
||||
<Button variant="primary" size="sm">
|
||||
<Target className="w-4 h-4 mr-1" />
|
||||
{t('ai.actions.implement_all')}
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className="space-y-6">
|
||||
{/* AI Insights Overview Stats */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<div className="text-center p-4 bg-[var(--bg-secondary)] rounded-lg">
|
||||
<Brain className="w-8 h-8 mx-auto text-purple-600 mb-2" />
|
||||
<p className="text-2xl font-bold text-[var(--text-primary)]">{activeInsights.length}</p>
|
||||
<p className="text-sm text-[var(--text-secondary)]">{t('ai.stats.active_insights')}</p>
|
||||
</div>
|
||||
<div className="text-center p-4 bg-[var(--bg-secondary)] rounded-lg">
|
||||
<AlertTriangle className="w-8 h-8 mx-auto text-red-600 mb-2" />
|
||||
<p className="text-2xl font-bold text-[var(--text-primary)]">{highPriorityInsights.length}</p>
|
||||
<p className="text-sm text-[var(--text-secondary)]">{t('ai.stats.high_priority')}</p>
|
||||
</div>
|
||||
<div className="text-center p-4 bg-[var(--bg-secondary)] rounded-lg">
|
||||
<div className="w-8 h-8 mx-auto bg-green-100 dark:bg-green-900/20 rounded-full flex items-center justify-center mb-2">
|
||||
<span className="text-green-600 font-bold text-sm">€</span>
|
||||
</div>
|
||||
<p className="text-2xl font-bold text-[var(--text-primary)]">€{totalPotentialSavings}</p>
|
||||
<p className="text-sm text-[var(--text-secondary)]">{t('ai.stats.potential_savings')}</p>
|
||||
</div>
|
||||
<div className="text-center p-4 bg-[var(--bg-secondary)] rounded-lg">
|
||||
<div className="w-8 h-8 mx-auto bg-blue-100 dark:bg-blue-900/20 rounded-full flex items-center justify-center mb-2">
|
||||
<span className="text-blue-600 font-bold text-sm">%</span>
|
||||
</div>
|
||||
<p className="text-2xl font-bold text-[var(--text-primary)]">{avgConfidence.toFixed(0)}%</p>
|
||||
<p className="text-sm text-[var(--text-secondary)]">{t('ai.stats.avg_confidence')}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* AI Status */}
|
||||
<div className="p-4 bg-green-50 dark:bg-green-900/20 rounded-lg">
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="w-2 h-2 bg-green-500 rounded-full animate-pulse"></div>
|
||||
<span className="text-sm font-medium text-green-600">
|
||||
{t('ai.status.active')}
|
||||
</span>
|
||||
<span className="text-xs text-[var(--text-secondary)] ml-2">
|
||||
{t('ai.last_updated')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* High Priority Insights */}
|
||||
{highPriorityInsights.length > 0 && (
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-[var(--text-primary)] mb-3 flex items-center">
|
||||
<AlertTriangle className="w-4 h-4 mr-2 text-red-600" />
|
||||
{t('ai.high_priority_insights')} ({highPriorityInsights.length})
|
||||
</h4>
|
||||
<div className="space-y-3">
|
||||
{highPriorityInsights.slice(0, 3).map((insight) => {
|
||||
const TypeIcon = getTypeIcon(insight.type);
|
||||
const ImpactIcon = getImpactIcon(insight.impact.type);
|
||||
|
||||
return (
|
||||
<div key={insight.id} className="p-4 bg-[var(--bg-secondary)] rounded-lg border border-red-200 dark:border-red-800">
|
||||
<div className="flex items-start justify-between mb-3">
|
||||
<div className="flex items-start space-x-3">
|
||||
<TypeIcon className={`w-5 h-5 mt-1 ${getTypeColor(insight.type)}`} />
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center space-x-2 mb-1">
|
||||
<p className="font-medium text-[var(--text-primary)]">{insight.title}</p>
|
||||
<Badge variant={getPriorityBadgeVariant(insight.priority)}>
|
||||
{t(`ai.priority.${insight.priority}`)}
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="text-sm text-[var(--text-secondary)] mb-2">{insight.description}</p>
|
||||
<div className="flex items-center space-x-4 text-xs text-[var(--text-secondary)]">
|
||||
<span>{t('ai.confidence')}: {insight.confidence}%</span>
|
||||
<span>{t('ai.timeline')}: {insight.timeline}</span>
|
||||
{insight.equipment && <span>{t('ai.equipment')}: {insight.equipment}</span>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Badge variant={getStatusBadgeVariant(insight.status)}>
|
||||
{t(`ai.status.${insight.status}`)}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{/* Impact */}
|
||||
<div className="flex items-center justify-between p-3 bg-[var(--bg-tertiary)] rounded-lg">
|
||||
<div className="flex items-center space-x-2">
|
||||
<ImpactIcon className="w-4 h-4 text-[var(--color-primary)]" />
|
||||
<span className="text-sm font-medium text-[var(--text-primary)]">
|
||||
{t(`ai.impact.${insight.impact.type}`)}
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-lg font-bold text-green-600">
|
||||
{formatImpactValue(insight.impact)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{insight.actionable && insight.status === 'new' && (
|
||||
<div className="mt-3 flex space-x-2">
|
||||
<Button variant="outline" size="sm">
|
||||
{t('ai.actions.acknowledge')}
|
||||
</Button>
|
||||
<Button variant="primary" size="sm">
|
||||
{t('ai.actions.implement')}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* All Insights */}
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-[var(--text-primary)] mb-3 flex items-center">
|
||||
<Brain className="w-4 h-4 mr-2" />
|
||||
{t('ai.all_insights')} ({activeInsights.length})
|
||||
</h4>
|
||||
<div className="space-y-3 max-h-96 overflow-y-auto">
|
||||
{activeInsights.map((insight) => {
|
||||
const TypeIcon = getTypeIcon(insight.type);
|
||||
const ImpactIcon = getImpactIcon(insight.impact.type);
|
||||
|
||||
return (
|
||||
<div key={insight.id} className="p-3 bg-[var(--bg-secondary)] rounded-lg hover:bg-[var(--bg-tertiary)] transition-colors">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-3">
|
||||
<TypeIcon className={`w-4 h-4 ${getTypeColor(insight.type)}`} />
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center space-x-2">
|
||||
<p className="font-medium text-[var(--text-primary)] text-sm">{insight.title}</p>
|
||||
<Badge variant={getPriorityBadgeVariant(insight.priority)} className="text-xs">
|
||||
{t(`ai.priority.${insight.priority}`)}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="flex items-center space-x-4 text-xs text-[var(--text-secondary)] mt-1">
|
||||
<span>{insight.confidence}% {t('ai.confidence')}</span>
|
||||
<span>•</span>
|
||||
<span>{insight.timeline}</span>
|
||||
<span>•</span>
|
||||
<div className="flex items-center space-x-1">
|
||||
<ImpactIcon className="w-3 h-3" />
|
||||
<span>{formatImpactValue(insight.impact)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Badge variant={getStatusBadgeVariant(insight.status)} className="text-xs">
|
||||
{t(`ai.status.${insight.status}`)}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* AI Performance Summary */}
|
||||
<div className="p-4 bg-blue-50 dark:bg-blue-900/20 rounded-lg">
|
||||
<div className="flex items-start space-x-2">
|
||||
<Brain className="w-5 h-5 text-blue-600 mt-0.5" />
|
||||
<div>
|
||||
<p className="text-sm font-medium text-blue-600">
|
||||
{t('ai.performance.summary')}
|
||||
</p>
|
||||
<p className="text-xs text-[var(--text-secondary)] mt-1">
|
||||
{implementedInsights.length} {t('ai.performance.insights_implemented')},
|
||||
{totalPotentialSavings > 0 && ` €${totalPotentialSavings} ${t('ai.performance.in_savings_identified')}`}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</AnalyticsWidget>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,253 @@
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { BarChart3, TrendingUp, AlertTriangle, Zap } from 'lucide-react';
|
||||
import { AnalyticsWidget } from '../AnalyticsWidget';
|
||||
import { AnalyticsChart, ChartSeries } from '../AnalyticsChart';
|
||||
import { Button, ProgressBar } from '../../../../ui';
|
||||
import { useProductionDashboard, useCapacityStatus } from '../../../../../api/hooks/production';
|
||||
import { useCurrentTenant } from '../../../../../stores/tenant.store';
|
||||
|
||||
interface CapacityData {
|
||||
resource: string;
|
||||
utilization: number;
|
||||
capacity: number;
|
||||
allocated: number;
|
||||
available: number;
|
||||
type: 'oven' | 'mixer' | 'staff' | 'station';
|
||||
}
|
||||
|
||||
export const CapacityUtilizationWidget: React.FC = () => {
|
||||
const { t } = useTranslation('production');
|
||||
const currentTenant = useCurrentTenant();
|
||||
const tenantId = currentTenant?.id || '';
|
||||
const today = new Date().toISOString().split('T')[0];
|
||||
|
||||
const { data: dashboard, isLoading: dashboardLoading, error: dashboardError } = useProductionDashboard(tenantId);
|
||||
const { data: capacity, isLoading: capacityLoading, error: capacityError } = useCapacityStatus(tenantId, today);
|
||||
|
||||
const isLoading = dashboardLoading || capacityLoading;
|
||||
const error = dashboardError?.message || capacityError?.message;
|
||||
|
||||
const overallUtilization = dashboard?.capacity_utilization || 0;
|
||||
|
||||
// Mock capacity data by resource type (in real implementation, this would be processed from capacity API)
|
||||
const getCapacityByResource = (): CapacityData[] => {
|
||||
return [
|
||||
{
|
||||
resource: t('equipment.oven_capacity'),
|
||||
utilization: 89,
|
||||
capacity: 24,
|
||||
allocated: 21,
|
||||
available: 3,
|
||||
type: 'oven'
|
||||
},
|
||||
{
|
||||
resource: t('equipment.mixer_capacity'),
|
||||
utilization: 67,
|
||||
capacity: 12,
|
||||
allocated: 8,
|
||||
available: 4,
|
||||
type: 'mixer'
|
||||
},
|
||||
{
|
||||
resource: t('schedule.staff_capacity'),
|
||||
utilization: 85,
|
||||
capacity: 8,
|
||||
allocated: 7,
|
||||
available: 1,
|
||||
type: 'staff'
|
||||
},
|
||||
{
|
||||
resource: t('schedule.work_stations'),
|
||||
utilization: 75,
|
||||
capacity: 6,
|
||||
allocated: 4,
|
||||
available: 2,
|
||||
type: 'station'
|
||||
}
|
||||
];
|
||||
};
|
||||
|
||||
const capacityData = getCapacityByResource();
|
||||
|
||||
// Heatmap data for hourly utilization
|
||||
const getHourlyUtilizationData = (): ChartSeries[] => {
|
||||
const hours = Array.from({ length: 12 }, (_, i) => i + 6); // 6 AM to 6 PM
|
||||
const mockData = hours.map(hour => ({
|
||||
x: `${hour}:00`,
|
||||
y: Math.max(20, Math.min(100, 50 + Math.sin(hour / 2) * 30 + Math.random() * 20)),
|
||||
label: `${hour}:00`
|
||||
}));
|
||||
|
||||
return [
|
||||
{
|
||||
id: 'hourly-utilization',
|
||||
name: t('insights.hourly_utilization'),
|
||||
type: 'area',
|
||||
color: '#d97706',
|
||||
data: mockData
|
||||
}
|
||||
];
|
||||
};
|
||||
|
||||
const getUtilizationStatus = (utilization: number) => {
|
||||
if (utilization >= 95) return { color: 'text-red-600', status: 'critical', bgColor: 'bg-red-100 dark:bg-red-900/20' };
|
||||
if (utilization >= 85) return { color: 'text-orange-600', status: 'high', bgColor: 'bg-orange-100 dark:bg-orange-900/20' };
|
||||
if (utilization >= 70) return { color: 'text-green-600', status: 'optimal', bgColor: 'bg-green-100 dark:bg-green-900/20' };
|
||||
return { color: 'text-blue-600', status: 'low', bgColor: 'bg-blue-100 dark:bg-blue-900/20' };
|
||||
};
|
||||
|
||||
const getResourceIcon = (type: CapacityData['type']) => {
|
||||
switch (type) {
|
||||
case 'oven': return '🔥';
|
||||
case 'mixer': return '🥄';
|
||||
case 'staff': return '👥';
|
||||
case 'station': return '🏭';
|
||||
default: return '📊';
|
||||
}
|
||||
};
|
||||
|
||||
const getProgressBarVariant = (utilization: number) => {
|
||||
if (utilization >= 95) return 'error';
|
||||
if (utilization >= 85) return 'warning';
|
||||
if (utilization >= 70) return 'success';
|
||||
return 'info';
|
||||
};
|
||||
|
||||
const overallStatus = getUtilizationStatus(overallUtilization);
|
||||
|
||||
return (
|
||||
<AnalyticsWidget
|
||||
title={t('stats.capacity_utilization')}
|
||||
subtitle={t('insights.resource_allocation_efficiency')}
|
||||
icon={BarChart3}
|
||||
loading={isLoading}
|
||||
error={error}
|
||||
actions={
|
||||
<Button variant="outline" size="sm">
|
||||
<Zap className="w-4 h-4 mr-1" />
|
||||
{t('actions.optimize')}
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
{/* Overall Utilization */}
|
||||
<div className="text-center">
|
||||
<div className="flex items-center justify-center space-x-2 mb-2">
|
||||
<BarChart3 className={`w-6 h-6 ${overallStatus.color}`} />
|
||||
<span className="text-3xl font-bold text-[var(--text-primary)]">
|
||||
{overallUtilization.toFixed(1)}%
|
||||
</span>
|
||||
</div>
|
||||
<div className={`inline-flex items-center space-x-2 px-3 py-1 rounded-full text-sm ${overallStatus.bgColor}`}>
|
||||
<span className={overallStatus.color}>
|
||||
{t(`status.${overallStatus.status}`)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Resource Breakdown */}
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-[var(--text-primary)] mb-3">
|
||||
{t('insights.resource_breakdown')}
|
||||
</h4>
|
||||
<div className="space-y-3">
|
||||
{capacityData.map((resource, index) => {
|
||||
const status = getUtilizationStatus(resource.utilization);
|
||||
|
||||
return (
|
||||
<div key={index} className="p-3 bg-[var(--bg-secondary)] rounded-lg">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="flex items-center space-x-2">
|
||||
<span className="text-lg">{getResourceIcon(resource.type)}</span>
|
||||
<span className="text-sm font-medium text-[var(--text-primary)]">
|
||||
{resource.resource}
|
||||
</span>
|
||||
</div>
|
||||
<span className={`text-sm font-semibold ${status.color}`}>
|
||||
{resource.utilization}%
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<ProgressBar
|
||||
value={resource.utilization}
|
||||
variant={getProgressBarVariant(resource.utilization)}
|
||||
size="sm"
|
||||
className="mb-2"
|
||||
/>
|
||||
|
||||
<div className="flex items-center justify-between text-xs text-[var(--text-secondary)]">
|
||||
<span>
|
||||
{resource.allocated}/{resource.capacity} {t('common.allocated')}
|
||||
</span>
|
||||
<span>
|
||||
{resource.available} {t('common.available')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Hourly Heatmap */}
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-[var(--text-primary)] mb-3">
|
||||
{t('insights.hourly_utilization_pattern')}
|
||||
</h4>
|
||||
<AnalyticsChart
|
||||
series={getHourlyUtilizationData()}
|
||||
height={120}
|
||||
showLegend={false}
|
||||
showGrid={true}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Alerts & Recommendations */}
|
||||
<div className="space-y-2">
|
||||
{capacityData.some(r => r.utilization >= 95) && (
|
||||
<div className="flex items-start space-x-2 p-3 bg-red-50 dark:bg-red-900/20 rounded-lg">
|
||||
<AlertTriangle className="w-4 h-4 mt-0.5 text-red-600" />
|
||||
<div>
|
||||
<p className="text-sm font-medium text-red-600">
|
||||
{t('alerts.capacity_critical')}
|
||||
</p>
|
||||
<p className="text-xs text-[var(--text-secondary)] mt-1">
|
||||
{t('alerts.capacity_critical_description')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{capacityData.some(r => r.utilization >= 85 && r.utilization < 95) && (
|
||||
<div className="flex items-start space-x-2 p-3 bg-orange-50 dark:bg-orange-900/20 rounded-lg">
|
||||
<TrendingUp className="w-4 h-4 mt-0.5 text-orange-600" />
|
||||
<div>
|
||||
<p className="text-sm font-medium text-orange-600">
|
||||
{t('alerts.capacity_high')}
|
||||
</p>
|
||||
<p className="text-xs text-[var(--text-secondary)] mt-1">
|
||||
{t('alerts.capacity_high_description')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Peak Hours Summary */}
|
||||
<div className="pt-3 border-t border-[var(--border-primary)]">
|
||||
<div className="grid grid-cols-2 gap-3 text-center text-sm">
|
||||
<div>
|
||||
<p className="font-semibold text-[var(--text-primary)]">10:00 - 12:00</p>
|
||||
<p className="text-xs text-[var(--text-secondary)]">{t('insights.peak_hours')}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-semibold text-[var(--text-primary)]">14:00 - 16:00</p>
|
||||
<p className="text-xs text-[var(--text-secondary)]">{t('insights.off_peak_optimal')}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</AnalyticsWidget>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,296 @@
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { DollarSign, TrendingUp, TrendingDown, PieChart } from 'lucide-react';
|
||||
import { AnalyticsWidget } from '../AnalyticsWidget';
|
||||
import { AnalyticsChart, ChartSeries } from '../AnalyticsChart';
|
||||
import { Button } from '../../../../ui';
|
||||
import { useActiveBatches } from '../../../../../api/hooks/production';
|
||||
import { useCurrentTenant } from '../../../../../stores/tenant.store';
|
||||
|
||||
interface ProductCostData {
|
||||
product: string;
|
||||
estimatedCost: number;
|
||||
actualCost: number;
|
||||
variance: number;
|
||||
variancePercent: number;
|
||||
units: number;
|
||||
costPerUnit: number;
|
||||
}
|
||||
|
||||
export const CostPerUnitWidget: React.FC = () => {
|
||||
const { t } = useTranslation('production');
|
||||
const currentTenant = useCurrentTenant();
|
||||
const tenantId = currentTenant?.id || '';
|
||||
|
||||
const { data: batchesData, isLoading, error } = useActiveBatches(tenantId);
|
||||
const batches = batchesData?.batches || [];
|
||||
|
||||
// Process batches to calculate cost per unit data
|
||||
const getProductCostData = (): ProductCostData[] => {
|
||||
const productMap = new Map<string, any>();
|
||||
|
||||
batches.forEach(batch => {
|
||||
const key = batch.product_name;
|
||||
if (!productMap.has(key)) {
|
||||
productMap.set(key, {
|
||||
product: batch.product_name,
|
||||
estimatedCosts: [],
|
||||
actualCosts: [],
|
||||
quantities: []
|
||||
});
|
||||
}
|
||||
|
||||
const data = productMap.get(key);
|
||||
if (batch.estimated_cost) data.estimatedCosts.push(batch.estimated_cost);
|
||||
if (batch.actual_cost) data.actualCosts.push(batch.actual_cost);
|
||||
if (batch.actual_quantity || batch.planned_quantity) {
|
||||
data.quantities.push(batch.actual_quantity || batch.planned_quantity);
|
||||
}
|
||||
});
|
||||
|
||||
return Array.from(productMap.values()).map(data => {
|
||||
const avgEstimated = data.estimatedCosts.reduce((a: number, b: number) => a + b, 0) / (data.estimatedCosts.length || 1);
|
||||
const avgActual = data.actualCosts.reduce((a: number, b: number) => a + b, 0) / (data.actualCosts.length || 1);
|
||||
const totalUnits = data.quantities.reduce((a: number, b: number) => a + b, 0);
|
||||
const variance = avgActual - avgEstimated;
|
||||
const variancePercent = avgEstimated > 0 ? (variance / avgEstimated) * 100 : 0;
|
||||
const costPerUnit = totalUnits > 0 ? avgActual / totalUnits : avgActual;
|
||||
|
||||
return {
|
||||
product: data.product,
|
||||
estimatedCost: avgEstimated,
|
||||
actualCost: avgActual,
|
||||
variance,
|
||||
variancePercent,
|
||||
units: totalUnits,
|
||||
costPerUnit
|
||||
};
|
||||
}).filter(item => item.actualCost > 0);
|
||||
};
|
||||
|
||||
const productCostData = getProductCostData();
|
||||
const totalCosts = productCostData.reduce((sum, item) => sum + item.actualCost, 0);
|
||||
const averageCostPerUnit = productCostData.length > 0
|
||||
? productCostData.reduce((sum, item) => sum + item.costPerUnit, 0) / productCostData.length
|
||||
: 0;
|
||||
|
||||
// Create chart data for cost comparison
|
||||
const getCostComparisonChartData = (): ChartSeries[] => {
|
||||
if (productCostData.length === 0) return [];
|
||||
|
||||
const estimatedData = productCostData.map(item => ({
|
||||
x: item.product,
|
||||
y: item.estimatedCost,
|
||||
label: item.product
|
||||
}));
|
||||
|
||||
const actualData = productCostData.map(item => ({
|
||||
x: item.product,
|
||||
y: item.actualCost,
|
||||
label: item.product
|
||||
}));
|
||||
|
||||
return [
|
||||
{
|
||||
id: 'estimated-cost',
|
||||
name: t('cost.estimated_cost'),
|
||||
type: 'bar',
|
||||
color: '#94a3b8',
|
||||
data: estimatedData
|
||||
},
|
||||
{
|
||||
id: 'actual-cost',
|
||||
name: t('cost.actual_cost'),
|
||||
type: 'bar',
|
||||
color: '#d97706',
|
||||
data: actualData
|
||||
}
|
||||
];
|
||||
};
|
||||
|
||||
// Create pie chart for cost distribution
|
||||
const getCostDistributionChartData = (): ChartSeries[] => {
|
||||
if (productCostData.length === 0) return [];
|
||||
|
||||
const pieData = productCostData.map((item, index) => ({
|
||||
x: item.product,
|
||||
y: item.actualCost,
|
||||
label: item.product,
|
||||
color: getProductColor(index)
|
||||
}));
|
||||
|
||||
return [
|
||||
{
|
||||
id: 'cost-distribution',
|
||||
name: t('cost.cost_distribution'),
|
||||
type: 'pie',
|
||||
color: '#d97706',
|
||||
data: pieData
|
||||
}
|
||||
];
|
||||
};
|
||||
|
||||
const getProductColor = (index: number): string => {
|
||||
const colors = ['#d97706', '#0284c7', '#16a34a', '#dc2626', '#7c3aed', '#0891b2'];
|
||||
return colors[index % colors.length];
|
||||
};
|
||||
|
||||
const getVarianceStatus = (variancePercent: number) => {
|
||||
if (Math.abs(variancePercent) <= 5) return { color: 'text-green-600', status: 'on_target' };
|
||||
if (variancePercent > 5) return { color: 'text-red-600', status: 'over_budget' };
|
||||
return { color: 'text-blue-600', status: 'under_budget' };
|
||||
};
|
||||
|
||||
return (
|
||||
<AnalyticsWidget
|
||||
title={t('cost.cost_per_unit_analysis')}
|
||||
subtitle={t('cost.estimated_vs_actual_costs')}
|
||||
icon={DollarSign}
|
||||
loading={isLoading}
|
||||
error={error?.message}
|
||||
actions={
|
||||
<Button variant="outline" size="sm">
|
||||
<PieChart className="w-4 h-4 mr-1" />
|
||||
{t('cost.view_breakdown')}
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
{/* Overall Metrics */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="p-4 bg-[var(--bg-secondary)] rounded-lg text-center">
|
||||
<div className="flex items-center justify-center space-x-2 mb-2">
|
||||
<DollarSign className="w-5 h-5 text-[var(--color-primary)]" />
|
||||
<span className="text-lg font-bold text-[var(--text-primary)]">
|
||||
€{averageCostPerUnit.toFixed(2)}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm text-[var(--text-secondary)]">{t('cost.average_cost_per_unit')}</p>
|
||||
</div>
|
||||
<div className="p-4 bg-[var(--bg-secondary)] rounded-lg text-center">
|
||||
<div className="flex items-center justify-center space-x-2 mb-2">
|
||||
<TrendingUp className="w-5 h-5 text-[var(--color-primary)]" />
|
||||
<span className="text-lg font-bold text-[var(--text-primary)]">
|
||||
€{totalCosts.toFixed(0)}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm text-[var(--text-secondary)]">{t('cost.total_production_cost')}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{productCostData.length === 0 ? (
|
||||
<div className="text-center py-8 text-[var(--text-secondary)]">
|
||||
<DollarSign className="w-12 h-12 mx-auto mb-3 opacity-50" />
|
||||
<p className="text-sm">{t('cost.no_cost_data_available')}</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Cost Comparison Chart */}
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-[var(--text-primary)] mb-3">
|
||||
{t('cost.estimated_vs_actual')}
|
||||
</h4>
|
||||
<AnalyticsChart
|
||||
series={getCostComparisonChartData()}
|
||||
height={200}
|
||||
showLegend={true}
|
||||
showGrid={true}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Product Cost Details */}
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-[var(--text-primary)] mb-3">
|
||||
{t('cost.product_cost_breakdown')}
|
||||
</h4>
|
||||
<div className="space-y-3 max-h-64 overflow-y-auto">
|
||||
{productCostData.map((item, index) => {
|
||||
const varianceStatus = getVarianceStatus(item.variancePercent);
|
||||
const VarianceIcon = item.variance >= 0 ? TrendingUp : TrendingDown;
|
||||
|
||||
return (
|
||||
<div key={index} className="p-3 bg-[var(--bg-secondary)] rounded-lg">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="flex items-center space-x-3">
|
||||
<div
|
||||
className="w-3 h-3 rounded-full"
|
||||
style={{ backgroundColor: getProductColor(index) }}
|
||||
/>
|
||||
<span className="font-medium text-[var(--text-primary)]">
|
||||
{item.product}
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-lg font-bold text-[var(--text-primary)]">
|
||||
€{item.costPerUnit.toFixed(2)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-3 gap-3 text-sm">
|
||||
<div>
|
||||
<p className="text-[var(--text-secondary)]">{t('cost.estimated')}</p>
|
||||
<p className="font-semibold text-[var(--text-primary)]">
|
||||
€{item.estimatedCost.toFixed(2)}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-[var(--text-secondary)]">{t('cost.actual')}</p>
|
||||
<p className="font-semibold text-[var(--text-primary)]">
|
||||
€{item.actualCost.toFixed(2)}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-[var(--text-secondary)]">{t('cost.variance')}</p>
|
||||
<div className={`flex items-center space-x-1 ${varianceStatus.color}`}>
|
||||
<VarianceIcon className="w-3 h-3" />
|
||||
<span className="font-semibold">
|
||||
{item.variancePercent > 0 ? '+' : ''}{item.variancePercent.toFixed(1)}%
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-2 pt-2 border-t border-[var(--border-primary)] text-xs text-[var(--text-tertiary)]">
|
||||
{t('cost.units_produced')}: {item.units}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Cost Distribution Pie Chart */}
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-[var(--text-primary)] mb-3">
|
||||
{t('cost.cost_distribution')}
|
||||
</h4>
|
||||
<AnalyticsChart
|
||||
series={getCostDistributionChartData()}
|
||||
height={200}
|
||||
showLegend={true}
|
||||
showGrid={false}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Cost Optimization Insights */}
|
||||
<div className="p-3 bg-blue-50 dark:bg-blue-900/20 rounded-lg">
|
||||
<div className="flex items-start space-x-2">
|
||||
<DollarSign className="w-4 h-4 mt-0.5 text-blue-600" />
|
||||
<div>
|
||||
<p className="text-sm font-medium text-blue-600">
|
||||
{t('cost.optimization_opportunity')}
|
||||
</p>
|
||||
<p className="text-xs text-[var(--text-secondary)] mt-1">
|
||||
{productCostData.some(item => item.variancePercent > 10)
|
||||
? t('cost.high_variance_detected')
|
||||
: t('cost.costs_within_expected_range')
|
||||
}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</AnalyticsWidget>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,389 @@
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Zap, TrendingUp, TrendingDown, BarChart3, Target } from 'lucide-react';
|
||||
import { AnalyticsWidget } from '../AnalyticsWidget';
|
||||
import { AnalyticsChart, ChartSeries } from '../AnalyticsChart';
|
||||
import { Button } from '../../../../ui';
|
||||
import { useCurrentTenant } from '../../../../../stores/tenant.store';
|
||||
|
||||
interface EquipmentEfficiencyData {
|
||||
equipmentId: string;
|
||||
equipmentName: string;
|
||||
type: 'oven' | 'mixer' | 'proofer' | 'packaging';
|
||||
currentEfficiency: number;
|
||||
targetEfficiency: number;
|
||||
trend: 'up' | 'down' | 'stable';
|
||||
energyConsumption: number; // kWh
|
||||
productionOutput: number; // units per hour
|
||||
oee: number; // Overall Equipment Effectiveness
|
||||
availability: number;
|
||||
performance: number;
|
||||
quality: number;
|
||||
downtimeMinutes: number;
|
||||
weeklyData: Array<{
|
||||
day: string;
|
||||
efficiency: number;
|
||||
energyConsumption: number;
|
||||
output: number;
|
||||
}>;
|
||||
}
|
||||
|
||||
export const EquipmentEfficiencyWidget: React.FC = () => {
|
||||
const { t } = useTranslation('production');
|
||||
const currentTenant = useCurrentTenant();
|
||||
|
||||
// Mock efficiency data - replace with real API call
|
||||
const efficiencyData: EquipmentEfficiencyData[] = [
|
||||
{
|
||||
equipmentId: '1',
|
||||
equipmentName: 'Horno Principal',
|
||||
type: 'oven',
|
||||
currentEfficiency: 94,
|
||||
targetEfficiency: 95,
|
||||
trend: 'up',
|
||||
energyConsumption: 45.2,
|
||||
productionOutput: 120,
|
||||
oee: 89,
|
||||
availability: 95,
|
||||
performance: 96,
|
||||
quality: 98,
|
||||
downtimeMinutes: 15,
|
||||
weeklyData: [
|
||||
{ day: 'Lun', efficiency: 92, energyConsumption: 44, output: 118 },
|
||||
{ day: 'Mar', efficiency: 93, energyConsumption: 45, output: 119 },
|
||||
{ day: 'Mié', efficiency: 94, energyConsumption: 45.2, output: 120 },
|
||||
{ day: 'Jue', efficiency: 94, energyConsumption: 45.1, output: 121 },
|
||||
{ day: 'Vie', efficiency: 95, energyConsumption: 45.3, output: 122 },
|
||||
{ day: 'Sáb', efficiency: 94, energyConsumption: 44.8, output: 119 },
|
||||
{ day: 'Dom', efficiency: 93, energyConsumption: 44.5, output: 117 }
|
||||
]
|
||||
},
|
||||
{
|
||||
equipmentId: '2',
|
||||
equipmentName: 'Mezcladora A',
|
||||
type: 'mixer',
|
||||
currentEfficiency: 87,
|
||||
targetEfficiency: 90,
|
||||
trend: 'down',
|
||||
energyConsumption: 12.5,
|
||||
productionOutput: 85,
|
||||
oee: 82,
|
||||
availability: 92,
|
||||
performance: 89,
|
||||
quality: 95,
|
||||
downtimeMinutes: 45,
|
||||
weeklyData: [
|
||||
{ day: 'Lun', efficiency: 89, energyConsumption: 12, output: 88 },
|
||||
{ day: 'Mar', efficiency: 88, energyConsumption: 12.2, output: 87 },
|
||||
{ day: 'Mié', efficiency: 87, energyConsumption: 12.5, output: 85 },
|
||||
{ day: 'Jue', efficiency: 86, energyConsumption: 12.8, output: 84 },
|
||||
{ day: 'Vie', efficiency: 87, energyConsumption: 12.6, output: 86 },
|
||||
{ day: 'Sáb', efficiency: 88, energyConsumption: 12.3, output: 87 },
|
||||
{ day: 'Dom', efficiency: 87, energyConsumption: 12.4, output: 85 }
|
||||
]
|
||||
},
|
||||
{
|
||||
equipmentId: '3',
|
||||
equipmentName: 'Cámara de Fermentación 1',
|
||||
type: 'proofer',
|
||||
currentEfficiency: 96,
|
||||
targetEfficiency: 95,
|
||||
trend: 'stable',
|
||||
energyConsumption: 8.3,
|
||||
productionOutput: 95,
|
||||
oee: 94,
|
||||
availability: 98,
|
||||
performance: 97,
|
||||
quality: 99,
|
||||
downtimeMinutes: 5,
|
||||
weeklyData: [
|
||||
{ day: 'Lun', efficiency: 96, energyConsumption: 8.2, output: 94 },
|
||||
{ day: 'Mar', efficiency: 96, energyConsumption: 8.3, output: 95 },
|
||||
{ day: 'Mié', efficiency: 96, energyConsumption: 8.3, output: 95 },
|
||||
{ day: 'Jue', efficiency: 97, energyConsumption: 8.4, output: 96 },
|
||||
{ day: 'Vie', efficiency: 96, energyConsumption: 8.3, output: 95 },
|
||||
{ day: 'Sáb', efficiency: 95, energyConsumption: 8.2, output: 94 },
|
||||
{ day: 'Dom', efficiency: 96, energyConsumption: 8.3, output: 95 }
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
const getTrendIcon = (trend: EquipmentEfficiencyData['trend']) => {
|
||||
switch (trend) {
|
||||
case 'up': return TrendingUp;
|
||||
case 'down': return TrendingDown;
|
||||
case 'stable': return BarChart3;
|
||||
default: return BarChart3;
|
||||
}
|
||||
};
|
||||
|
||||
const getTrendColor = (trend: EquipmentEfficiencyData['trend']) => {
|
||||
switch (trend) {
|
||||
case 'up': return 'text-green-600';
|
||||
case 'down': return 'text-red-600';
|
||||
case 'stable': return 'text-blue-600';
|
||||
default: return 'text-gray-600';
|
||||
}
|
||||
};
|
||||
|
||||
const getEfficiencyStatus = (current: number, target: number) => {
|
||||
const percentage = (current / target) * 100;
|
||||
if (percentage >= 100) return { status: 'excellent', color: 'text-green-600', bgColor: 'bg-green-100 dark:bg-green-900/20' };
|
||||
if (percentage >= 90) return { status: 'good', color: 'text-blue-600', bgColor: 'bg-blue-100 dark:bg-blue-900/20' };
|
||||
if (percentage >= 80) return { status: 'warning', color: 'text-orange-600', bgColor: 'bg-orange-100 dark:bg-orange-900/20' };
|
||||
return { status: 'critical', color: 'text-red-600', bgColor: 'bg-red-100 dark:bg-red-900/20' };
|
||||
};
|
||||
|
||||
// Calculate overall metrics
|
||||
const avgEfficiency = efficiencyData.reduce((sum, item) => sum + item.currentEfficiency, 0) / efficiencyData.length;
|
||||
const avgOEE = efficiencyData.reduce((sum, item) => sum + item.oee, 0) / efficiencyData.length;
|
||||
const totalEnergyConsumption = efficiencyData.reduce((sum, item) => sum + item.energyConsumption, 0);
|
||||
const totalDowntime = efficiencyData.reduce((sum, item) => sum + item.downtimeMinutes, 0);
|
||||
|
||||
// Create efficiency comparison chart
|
||||
const getEfficiencyComparisonChartData = (): ChartSeries[] => {
|
||||
const currentData = efficiencyData.map(item => ({
|
||||
x: item.equipmentName,
|
||||
y: item.currentEfficiency,
|
||||
label: `${item.equipmentName}: ${item.currentEfficiency}%`
|
||||
}));
|
||||
|
||||
const targetData = efficiencyData.map(item => ({
|
||||
x: item.equipmentName,
|
||||
y: item.targetEfficiency,
|
||||
label: `Target: ${item.targetEfficiency}%`
|
||||
}));
|
||||
|
||||
return [
|
||||
{
|
||||
id: 'current-efficiency',
|
||||
name: t('equipment.efficiency.current'),
|
||||
type: 'bar',
|
||||
color: '#16a34a',
|
||||
data: currentData
|
||||
},
|
||||
{
|
||||
id: 'target-efficiency',
|
||||
name: t('equipment.efficiency.target'),
|
||||
type: 'bar',
|
||||
color: '#d97706',
|
||||
data: targetData
|
||||
}
|
||||
];
|
||||
};
|
||||
|
||||
// Create weekly efficiency trend chart
|
||||
const getWeeklyTrendChartData = (): ChartSeries[] => {
|
||||
return efficiencyData.map((equipment, index) => ({
|
||||
id: `weekly-${equipment.equipmentId}`,
|
||||
name: equipment.equipmentName,
|
||||
type: 'line',
|
||||
color: ['#16a34a', '#3b82f6', '#f59e0b'][index % 3],
|
||||
data: equipment.weeklyData.map(day => ({
|
||||
x: day.day,
|
||||
y: day.efficiency,
|
||||
label: `${day.day}: ${day.efficiency}%`
|
||||
}))
|
||||
}));
|
||||
};
|
||||
|
||||
// Create OEE breakdown chart
|
||||
const getOEEBreakdownChartData = (): ChartSeries[] => {
|
||||
const avgAvailability = efficiencyData.reduce((sum, item) => sum + item.availability, 0) / efficiencyData.length;
|
||||
const avgPerformance = efficiencyData.reduce((sum, item) => sum + item.performance, 0) / efficiencyData.length;
|
||||
const avgQuality = efficiencyData.reduce((sum, item) => sum + item.quality, 0) / efficiencyData.length;
|
||||
|
||||
return [
|
||||
{
|
||||
id: 'oee-breakdown',
|
||||
name: 'OEE Components',
|
||||
type: 'doughnut',
|
||||
color: '#3b82f6',
|
||||
data: [
|
||||
{ x: t('equipment.oee.availability'), y: avgAvailability, label: `${t('equipment.oee.availability')}: ${avgAvailability.toFixed(1)}%` },
|
||||
{ x: t('equipment.oee.performance'), y: avgPerformance, label: `${t('equipment.oee.performance')}: ${avgPerformance.toFixed(1)}%` },
|
||||
{ x: t('equipment.oee.quality'), y: avgQuality, label: `${t('equipment.oee.quality')}: ${avgQuality.toFixed(1)}%` }
|
||||
]
|
||||
}
|
||||
];
|
||||
};
|
||||
|
||||
return (
|
||||
<AnalyticsWidget
|
||||
title={t('equipment.efficiency.title')}
|
||||
subtitle={t('equipment.efficiency.subtitle')}
|
||||
icon={Zap}
|
||||
actions={
|
||||
<div className="flex space-x-2">
|
||||
<Button variant="outline" size="sm">
|
||||
<BarChart3 className="w-4 h-4 mr-1" />
|
||||
{t('equipment.efficiency.analyze')}
|
||||
</Button>
|
||||
<Button variant="primary" size="sm">
|
||||
<Target className="w-4 h-4 mr-1" />
|
||||
{t('equipment.efficiency.optimize')}
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className="space-y-6">
|
||||
{/* Efficiency Overview Stats */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<div className="text-center p-4 bg-[var(--bg-secondary)] rounded-lg">
|
||||
<Zap className="w-8 h-8 mx-auto text-green-600 mb-2" />
|
||||
<p className="text-2xl font-bold text-[var(--text-primary)]">{avgEfficiency.toFixed(1)}%</p>
|
||||
<p className="text-sm text-[var(--text-secondary)]">{t('equipment.efficiency.average')}</p>
|
||||
</div>
|
||||
<div className="text-center p-4 bg-[var(--bg-secondary)] rounded-lg">
|
||||
<Target className="w-8 h-8 mx-auto text-blue-600 mb-2" />
|
||||
<p className="text-2xl font-bold text-[var(--text-primary)]">{avgOEE.toFixed(1)}%</p>
|
||||
<p className="text-sm text-[var(--text-secondary)]">{t('equipment.oee.overall')}</p>
|
||||
</div>
|
||||
<div className="text-center p-4 bg-[var(--bg-secondary)] rounded-lg">
|
||||
<div className="w-8 h-8 mx-auto bg-orange-100 dark:bg-orange-900/20 rounded-full flex items-center justify-center mb-2">
|
||||
<span className="text-orange-600 font-bold text-sm">kWh</span>
|
||||
</div>
|
||||
<p className="text-2xl font-bold text-[var(--text-primary)]">{totalEnergyConsumption.toFixed(1)}</p>
|
||||
<p className="text-sm text-[var(--text-secondary)]">{t('equipment.energy_consumption')}</p>
|
||||
</div>
|
||||
<div className="text-center p-4 bg-[var(--bg-secondary)] rounded-lg">
|
||||
<div className="w-8 h-8 mx-auto bg-red-100 dark:bg-red-900/20 rounded-full flex items-center justify-center mb-2">
|
||||
<span className="text-red-600 font-bold text-xs">min</span>
|
||||
</div>
|
||||
<p className="text-2xl font-bold text-[var(--text-primary)]">{totalDowntime}</p>
|
||||
<p className="text-sm text-[var(--text-secondary)]">{t('equipment.downtime.total')}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Equipment Efficiency List */}
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-[var(--text-primary)] mb-3 flex items-center">
|
||||
<Zap className="w-4 h-4 mr-2" />
|
||||
{t('equipment.efficiency.by_equipment')}
|
||||
</h4>
|
||||
<div className="space-y-3">
|
||||
{efficiencyData.map((item) => {
|
||||
const TrendIcon = getTrendIcon(item.trend);
|
||||
const trendColor = getTrendColor(item.trend);
|
||||
const status = getEfficiencyStatus(item.currentEfficiency, item.targetEfficiency);
|
||||
|
||||
return (
|
||||
<div key={item.equipmentId} className="p-4 bg-[var(--bg-secondary)] rounded-lg">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="flex items-center space-x-3">
|
||||
<Zap className="w-5 h-5 text-[var(--color-primary)]" />
|
||||
<div>
|
||||
<p className="font-medium text-[var(--text-primary)]">{item.equipmentName}</p>
|
||||
<div className="flex items-center space-x-2 text-xs text-[var(--text-secondary)]">
|
||||
<span>OEE: {item.oee}%</span>
|
||||
<span>•</span>
|
||||
<span>{item.energyConsumption} kWh</span>
|
||||
<span>•</span>
|
||||
<span>{item.productionOutput} {t('equipment.units_per_hour')}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<TrendIcon className={`w-4 h-4 ${trendColor}`} />
|
||||
<div className="text-right">
|
||||
<div className="text-lg font-bold text-[var(--text-primary)]">
|
||||
{item.currentEfficiency}%
|
||||
</div>
|
||||
<div className="text-xs text-[var(--text-secondary)]">
|
||||
{t('equipment.efficiency.target')}: {item.targetEfficiency}%
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Progress bar */}
|
||||
<div className="w-full bg-[var(--bg-tertiary)] rounded-full h-2 mb-3">
|
||||
<div
|
||||
className="bg-[var(--color-primary)] h-2 rounded-full transition-all duration-300"
|
||||
style={{ width: `${(item.currentEfficiency / 100) * 100}%` }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* OEE Components */}
|
||||
<div className="grid grid-cols-3 gap-4 text-center">
|
||||
<div>
|
||||
<p className="text-lg font-semibold text-green-600">{item.availability}%</p>
|
||||
<p className="text-xs text-[var(--text-secondary)]">{t('equipment.oee.availability')}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-lg font-semibold text-blue-600">{item.performance}%</p>
|
||||
<p className="text-xs text-[var(--text-secondary)]">{t('equipment.oee.performance')}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-lg font-semibold text-orange-600">{item.quality}%</p>
|
||||
<p className="text-xs text-[var(--text-secondary)]">{t('equipment.oee.quality')}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Efficiency Charts */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{/* Current vs Target Efficiency */}
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-[var(--text-primary)] mb-3">
|
||||
{t('equipment.efficiency.current_vs_target')}
|
||||
</h4>
|
||||
<AnalyticsChart
|
||||
series={getEfficiencyComparisonChartData()}
|
||||
height={200}
|
||||
showLegend={true}
|
||||
showGrid={true}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* OEE Breakdown */}
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-[var(--text-primary)] mb-3">
|
||||
{t('equipment.oee.breakdown')}
|
||||
</h4>
|
||||
<AnalyticsChart
|
||||
series={getOEEBreakdownChartData()}
|
||||
height={200}
|
||||
showLegend={true}
|
||||
showGrid={false}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Weekly Efficiency Trends */}
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-[var(--text-primary)] mb-3">
|
||||
{t('equipment.efficiency.weekly_trends')}
|
||||
</h4>
|
||||
<AnalyticsChart
|
||||
series={getWeeklyTrendChartData()}
|
||||
height={250}
|
||||
showLegend={true}
|
||||
showGrid={true}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Efficiency Recommendations */}
|
||||
<div className="p-4 bg-blue-50 dark:bg-blue-900/20 rounded-lg">
|
||||
<div className="flex items-start space-x-2">
|
||||
<Target className="w-5 h-5 text-blue-600 mt-0.5" />
|
||||
<div>
|
||||
<p className="text-sm font-medium text-blue-600">
|
||||
{t('equipment.efficiency.recommendations')}
|
||||
</p>
|
||||
<ul className="text-xs text-[var(--text-secondary)] mt-1 space-y-1">
|
||||
<li>• {t('equipment.efficiency.recommendation_1')}: Mezcladora A {t('equipment.efficiency.needs_maintenance')}</li>
|
||||
<li>• {t('equipment.efficiency.recommendation_2')}: {t('equipment.efficiency.optimize_energy_consumption')}</li>
|
||||
<li>• {t('equipment.efficiency.recommendation_3')}: {t('equipment.efficiency.schedule_preventive_maintenance')}</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</AnalyticsWidget>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,285 @@
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Settings, AlertTriangle, CheckCircle, Clock, Zap } from 'lucide-react';
|
||||
import { AnalyticsWidget } from '../AnalyticsWidget';
|
||||
import { AnalyticsChart, ChartSeries } from '../AnalyticsChart';
|
||||
import { Badge, Button } from '../../../../ui';
|
||||
import { useCurrentTenant } from '../../../../../stores/tenant.store';
|
||||
|
||||
interface EquipmentStatus {
|
||||
id: string;
|
||||
name: string;
|
||||
type: 'oven' | 'mixer' | 'proofer' | 'packaging';
|
||||
status: 'operational' | 'warning' | 'maintenance' | 'down';
|
||||
efficiency: number;
|
||||
temperature?: number;
|
||||
uptime: number;
|
||||
lastMaintenance: string;
|
||||
nextMaintenance: string;
|
||||
alertCount: number;
|
||||
}
|
||||
|
||||
export const EquipmentStatusWidget: React.FC = () => {
|
||||
const { t } = useTranslation('production');
|
||||
const currentTenant = useCurrentTenant();
|
||||
|
||||
// Mock equipment data - replace with real API call
|
||||
const equipment: EquipmentStatus[] = [
|
||||
{
|
||||
id: '1',
|
||||
name: 'Horno Principal',
|
||||
type: 'oven',
|
||||
status: 'operational',
|
||||
efficiency: 94,
|
||||
temperature: 180,
|
||||
uptime: 98.5,
|
||||
lastMaintenance: '2024-01-15',
|
||||
nextMaintenance: '2024-02-15',
|
||||
alertCount: 0
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
name: 'Mezcladora A',
|
||||
type: 'mixer',
|
||||
status: 'warning',
|
||||
efficiency: 87,
|
||||
uptime: 85.2,
|
||||
lastMaintenance: '2024-01-10',
|
||||
nextMaintenance: '2024-02-10',
|
||||
alertCount: 2
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
name: 'Cámara de Fermentación 1',
|
||||
type: 'proofer',
|
||||
status: 'operational',
|
||||
efficiency: 96,
|
||||
temperature: 28,
|
||||
uptime: 99.1,
|
||||
lastMaintenance: '2024-01-20',
|
||||
nextMaintenance: '2024-02-20',
|
||||
alertCount: 0
|
||||
},
|
||||
{
|
||||
id: '4',
|
||||
name: 'Empaquetadora',
|
||||
type: 'packaging',
|
||||
status: 'maintenance',
|
||||
efficiency: 0,
|
||||
uptime: 0,
|
||||
lastMaintenance: '2024-01-25',
|
||||
nextMaintenance: '2024-01-26',
|
||||
alertCount: 1
|
||||
}
|
||||
];
|
||||
|
||||
const getStatusColor = (status: EquipmentStatus['status']) => {
|
||||
switch (status) {
|
||||
case 'operational': return 'text-green-600';
|
||||
case 'warning': return 'text-orange-600';
|
||||
case 'maintenance': return 'text-blue-600';
|
||||
case 'down': return 'text-red-600';
|
||||
default: return 'text-gray-600';
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusBadgeVariant = (status: EquipmentStatus['status']) => {
|
||||
switch (status) {
|
||||
case 'operational': return 'success';
|
||||
case 'warning': return 'warning';
|
||||
case 'maintenance': return 'info';
|
||||
case 'down': return 'error';
|
||||
default: return 'default';
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusIcon = (status: EquipmentStatus['status']) => {
|
||||
switch (status) {
|
||||
case 'operational': return CheckCircle;
|
||||
case 'warning': return AlertTriangle;
|
||||
case 'maintenance': return Clock;
|
||||
case 'down': return AlertTriangle;
|
||||
default: return Settings;
|
||||
}
|
||||
};
|
||||
|
||||
const operationalCount = equipment.filter(e => e.status === 'operational').length;
|
||||
const warningCount = equipment.filter(e => e.status === 'warning').length;
|
||||
const maintenanceCount = equipment.filter(e => e.status === 'maintenance').length;
|
||||
const downCount = equipment.filter(e => e.status === 'down').length;
|
||||
const avgEfficiency = equipment.reduce((sum, e) => sum + e.efficiency, 0) / equipment.length;
|
||||
|
||||
// Equipment efficiency chart data
|
||||
const getEfficiencyChartData = (): ChartSeries[] => {
|
||||
const data = equipment.map(item => ({
|
||||
x: item.name,
|
||||
y: item.efficiency,
|
||||
label: `${item.name}: ${item.efficiency}%`
|
||||
}));
|
||||
|
||||
return [
|
||||
{
|
||||
id: 'equipment-efficiency',
|
||||
name: t('equipment.efficiency'),
|
||||
type: 'bar',
|
||||
color: '#16a34a',
|
||||
data
|
||||
}
|
||||
];
|
||||
};
|
||||
|
||||
// Status distribution chart
|
||||
const getStatusDistributionChartData = (): ChartSeries[] => {
|
||||
const statusData = [
|
||||
{ x: t('equipment.status.operational'), y: operationalCount, label: `${operationalCount} ${t('equipment.status.operational')}` },
|
||||
{ x: t('equipment.status.warning'), y: warningCount, label: `${warningCount} ${t('equipment.status.warning')}` },
|
||||
{ x: t('equipment.status.maintenance'), y: maintenanceCount, label: `${maintenanceCount} ${t('equipment.status.maintenance')}` },
|
||||
{ x: t('equipment.status.down'), y: downCount, label: `${downCount} ${t('equipment.status.down')}` }
|
||||
].filter(item => item.y > 0);
|
||||
|
||||
return [
|
||||
{
|
||||
id: 'status-distribution',
|
||||
name: t('equipment.status.distribution'),
|
||||
type: 'doughnut',
|
||||
color: '#3b82f6',
|
||||
data: statusData
|
||||
}
|
||||
];
|
||||
};
|
||||
|
||||
return (
|
||||
<AnalyticsWidget
|
||||
title={t('equipment.title')}
|
||||
subtitle={t('equipment.subtitle')}
|
||||
icon={Settings}
|
||||
actions={
|
||||
<div className="flex space-x-2">
|
||||
<Button variant="outline" size="sm">
|
||||
<Clock className="w-4 h-4 mr-1" />
|
||||
{t('equipment.actions.schedule_maintenance')}
|
||||
</Button>
|
||||
<Button variant="primary" size="sm">
|
||||
<Zap className="w-4 h-4 mr-1" />
|
||||
{t('actions.optimize')}
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className="space-y-6">
|
||||
{/* Equipment Overview Stats */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<div className="text-center p-4 bg-[var(--bg-secondary)] rounded-lg">
|
||||
<CheckCircle className="w-8 h-8 mx-auto text-green-600 mb-2" />
|
||||
<p className="text-2xl font-bold text-[var(--text-primary)]">{operationalCount}</p>
|
||||
<p className="text-sm text-[var(--text-secondary)]">{t('equipment.stats.operational')}</p>
|
||||
</div>
|
||||
<div className="text-center p-4 bg-[var(--bg-secondary)] rounded-lg">
|
||||
<AlertTriangle className="w-8 h-8 mx-auto text-orange-600 mb-2" />
|
||||
<p className="text-2xl font-bold text-[var(--text-primary)]">{warningCount}</p>
|
||||
<p className="text-sm text-[var(--text-secondary)]">{t('equipment.stats.needs_attention')}</p>
|
||||
</div>
|
||||
<div className="text-center p-4 bg-[var(--bg-secondary)] rounded-lg">
|
||||
<div className="w-8 h-8 mx-auto bg-blue-100 dark:bg-blue-900/20 rounded-full flex items-center justify-center mb-2">
|
||||
<span className="text-blue-600 font-bold text-lg">{avgEfficiency.toFixed(0)}%</span>
|
||||
</div>
|
||||
<p className="text-2xl font-bold text-[var(--text-primary)]">{avgEfficiency.toFixed(1)}%</p>
|
||||
<p className="text-sm text-[var(--text-secondary)]">{t('equipment.stats.avg_efficiency')}</p>
|
||||
</div>
|
||||
<div className="text-center p-4 bg-[var(--bg-secondary)] rounded-lg">
|
||||
<AlertTriangle className="w-8 h-8 mx-auto text-red-600 mb-2" />
|
||||
<p className="text-2xl font-bold text-[var(--text-primary)]">
|
||||
{equipment.reduce((sum, e) => sum + e.alertCount, 0)}
|
||||
</p>
|
||||
<p className="text-sm text-[var(--text-secondary)]">{t('equipment.stats.alerts')}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Equipment List */}
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-[var(--text-primary)] mb-3 flex items-center">
|
||||
<Settings className="w-4 h-4 mr-2" />
|
||||
{t('equipment.status.equipment_list')} ({equipment.length})
|
||||
</h4>
|
||||
<div className="space-y-3">
|
||||
{equipment.map((item) => {
|
||||
const StatusIcon = getStatusIcon(item.status);
|
||||
return (
|
||||
<div key={item.id} className="p-4 bg-[var(--bg-secondary)] rounded-lg hover:bg-[var(--bg-tertiary)] transition-colors">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-3">
|
||||
<StatusIcon className={`w-5 h-5 ${getStatusColor(item.status)}`} />
|
||||
<div>
|
||||
<p className="font-medium text-[var(--text-primary)]">{item.name}</p>
|
||||
<div className="flex items-center space-x-4 text-xs text-[var(--text-secondary)]">
|
||||
<span>{t('equipment.efficiency')}: {item.efficiency}%</span>
|
||||
<span>{t('equipment.uptime')}: {item.uptime}%</span>
|
||||
{item.temperature && (
|
||||
<span>{t('equipment.temperature')}: {item.temperature}°C</span>
|
||||
)}
|
||||
{item.alertCount > 0 && (
|
||||
<span className="text-red-600">{item.alertCount} {t('equipment.unread_alerts')}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Badge variant={getStatusBadgeVariant(item.status)}>
|
||||
{t(`equipment.status.${item.status}`)}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Equipment Charts */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{/* Efficiency Chart */}
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-[var(--text-primary)] mb-3">
|
||||
{t('equipment.efficiency')} {t('stats.by_equipment')}
|
||||
</h4>
|
||||
<AnalyticsChart
|
||||
series={getEfficiencyChartData()}
|
||||
height={200}
|
||||
showLegend={false}
|
||||
showGrid={true}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Status Distribution */}
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-[var(--text-primary)] mb-3">
|
||||
{t('equipment.status.distribution')}
|
||||
</h4>
|
||||
<AnalyticsChart
|
||||
series={getStatusDistributionChartData()}
|
||||
height={200}
|
||||
showLegend={true}
|
||||
showGrid={false}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Maintenance Alerts */}
|
||||
{equipment.some(e => e.alertCount > 0 || e.status === 'maintenance') && (
|
||||
<div className="p-4 bg-orange-50 dark:bg-orange-900/20 rounded-lg">
|
||||
<div className="flex items-start space-x-2">
|
||||
<AlertTriangle className="w-5 h-5 text-orange-600 mt-0.5" />
|
||||
<div>
|
||||
<p className="text-sm font-medium text-orange-600">
|
||||
{t('equipment.alerts.maintenance_required')}
|
||||
</p>
|
||||
<p className="text-xs text-[var(--text-secondary)] mt-1">
|
||||
{equipment.filter(e => e.status === 'maintenance').length} {t('equipment.alerts.equipment_in_maintenance')},
|
||||
{equipment.reduce((sum, e) => sum + e.alertCount, 0)} {t('equipment.alerts.active_alerts')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</AnalyticsWidget>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,259 @@
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Activity, Clock, AlertCircle, CheckCircle, Play, Pause } from 'lucide-react';
|
||||
import { AnalyticsWidget } from '../AnalyticsWidget';
|
||||
import { Badge, ProgressBar, Button } from '../../../../ui';
|
||||
import { useActiveBatches } from '../../../../../api/hooks/production';
|
||||
import { useCurrentTenant } from '../../../../../stores/tenant.store';
|
||||
import { ProductionStatus } from '../../../../../api/types/production';
|
||||
|
||||
export const LiveBatchTrackerWidget: React.FC = () => {
|
||||
const { t } = useTranslation('production');
|
||||
const currentTenant = useCurrentTenant();
|
||||
const tenantId = currentTenant?.id || '';
|
||||
|
||||
const { data: batchesData, isLoading, error } = useActiveBatches(tenantId);
|
||||
const batches = batchesData?.batches || [];
|
||||
|
||||
const getStatusIcon = (status: ProductionStatus) => {
|
||||
switch (status) {
|
||||
case ProductionStatus.COMPLETED:
|
||||
return CheckCircle;
|
||||
case ProductionStatus.IN_PROGRESS:
|
||||
return Play;
|
||||
case ProductionStatus.ON_HOLD:
|
||||
return Pause;
|
||||
case ProductionStatus.FAILED:
|
||||
return AlertCircle;
|
||||
default:
|
||||
return Clock;
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusBadgeVariant = (status: ProductionStatus) => {
|
||||
switch (status) {
|
||||
case ProductionStatus.COMPLETED:
|
||||
return 'success';
|
||||
case ProductionStatus.IN_PROGRESS:
|
||||
return 'info';
|
||||
case ProductionStatus.PENDING:
|
||||
return 'warning';
|
||||
case ProductionStatus.ON_HOLD:
|
||||
return 'warning';
|
||||
case ProductionStatus.CANCELLED:
|
||||
case ProductionStatus.FAILED:
|
||||
return 'error';
|
||||
default:
|
||||
return 'default';
|
||||
}
|
||||
};
|
||||
|
||||
const calculateProgress = (batch: any) => {
|
||||
if (batch.status === ProductionStatus.COMPLETED) return 100;
|
||||
if (batch.status === ProductionStatus.PENDING) return 0;
|
||||
|
||||
if (batch.actual_start_time && batch.planned_duration_minutes) {
|
||||
const startTime = new Date(batch.actual_start_time).getTime();
|
||||
const now = new Date().getTime();
|
||||
const elapsed = (now - startTime) / (1000 * 60); // minutes
|
||||
const progress = Math.min((elapsed / batch.planned_duration_minutes) * 100, 100);
|
||||
return Math.round(progress);
|
||||
}
|
||||
|
||||
return 25; // Default for in-progress without timing info
|
||||
};
|
||||
|
||||
const calculateETA = (batch: any) => {
|
||||
if (batch.status === ProductionStatus.COMPLETED) return null;
|
||||
if (!batch.actual_start_time || !batch.planned_duration_minutes) return null;
|
||||
|
||||
const startTime = new Date(batch.actual_start_time).getTime();
|
||||
const eta = new Date(startTime + batch.planned_duration_minutes * 60 * 1000);
|
||||
const now = new Date();
|
||||
|
||||
if (eta < now) {
|
||||
const delay = Math.round((now.getTime() - eta.getTime()) / (1000 * 60));
|
||||
return `${delay}m ${t('schedule.delayed')}`;
|
||||
}
|
||||
|
||||
return eta.toLocaleTimeString('es-ES', { hour: '2-digit', minute: '2-digit' });
|
||||
};
|
||||
|
||||
const getProgressBarVariant = (status: ProductionStatus, progress: number) => {
|
||||
if (status === ProductionStatus.COMPLETED) return 'success';
|
||||
if (status === ProductionStatus.FAILED || status === ProductionStatus.CANCELLED) return 'error';
|
||||
if (progress > 90) return 'warning';
|
||||
return 'info';
|
||||
};
|
||||
|
||||
return (
|
||||
<AnalyticsWidget
|
||||
title={t('tracker.live_batch_tracker')}
|
||||
subtitle={t('tracker.current_batches_status_progress')}
|
||||
icon={Activity}
|
||||
loading={isLoading}
|
||||
error={error?.message}
|
||||
actions={
|
||||
<Button variant="outline" size="sm">
|
||||
<Activity className="w-4 h-4 mr-1" />
|
||||
{t('actions.refresh')}
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<div className="space-y-3">
|
||||
{batches.length === 0 ? (
|
||||
<div className="text-center py-8 text-[var(--text-secondary)]">
|
||||
<Activity className="w-12 h-12 mx-auto mb-3 opacity-50" />
|
||||
<h3 className="text-lg font-medium text-[var(--text-primary)] mb-2">
|
||||
{t('messages.no_active_batches')}
|
||||
</h3>
|
||||
<p className="text-sm">{t('messages.no_active_batches_description')}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3 max-h-96 overflow-y-auto">
|
||||
{batches.map((batch) => {
|
||||
const StatusIcon = getStatusIcon(batch.status);
|
||||
const progress = calculateProgress(batch);
|
||||
const eta = calculateETA(batch);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={batch.id}
|
||||
className="p-4 bg-[var(--bg-secondary)] rounded-lg border border-[var(--border-primary)] hover:shadow-md transition-all"
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-start justify-between mb-3">
|
||||
<div className="flex items-start space-x-3 flex-1">
|
||||
<div className="w-8 h-8 bg-[var(--color-primary)]/10 rounded-lg flex items-center justify-center mt-0.5">
|
||||
<StatusIcon className="w-4 h-4 text-[var(--color-primary)]" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h4 className="font-semibold text-[var(--text-primary)] mb-1">
|
||||
{batch.product_name}
|
||||
</h4>
|
||||
<p className="text-sm text-[var(--text-secondary)] mb-1">
|
||||
{t('batch.batch_number')}: {batch.batch_number}
|
||||
</p>
|
||||
<div className="flex items-center space-x-4 text-xs text-[var(--text-tertiary)]">
|
||||
<span>{batch.planned_quantity} {t('common.units')}</span>
|
||||
{batch.actual_quantity && (
|
||||
<span>
|
||||
{t('batch.actual')}: {batch.actual_quantity} {t('common.units')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Badge variant={getStatusBadgeVariant(batch.status)}>
|
||||
{t(`status.${batch.status.toLowerCase()}`)}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{/* Progress Bar */}
|
||||
{batch.status !== ProductionStatus.PENDING && (
|
||||
<div className="mb-3">
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<span className="text-xs text-[var(--text-secondary)]">
|
||||
{t('tracker.progress')}
|
||||
</span>
|
||||
<span className="text-xs font-medium text-[var(--text-primary)]">
|
||||
{progress}%
|
||||
</span>
|
||||
</div>
|
||||
<ProgressBar
|
||||
value={progress}
|
||||
variant={getProgressBarVariant(batch.status, progress)}
|
||||
size="sm"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Details */}
|
||||
<div className="flex items-center justify-between text-xs">
|
||||
<div className="flex items-center space-x-4">
|
||||
{batch.priority && (
|
||||
<span className={`font-medium ${
|
||||
batch.priority === 'URGENT' ? 'text-red-600' :
|
||||
batch.priority === 'HIGH' ? 'text-orange-600' :
|
||||
batch.priority === 'MEDIUM' ? 'text-blue-600' :
|
||||
'text-gray-600'
|
||||
}`}>
|
||||
{t(`priority.${batch.priority.toLowerCase()}`)}
|
||||
</span>
|
||||
)}
|
||||
{batch.is_rush_order && (
|
||||
<span className="text-red-600 font-medium">
|
||||
{t('batch.rush_order')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center space-x-1 text-[var(--text-secondary)]">
|
||||
<Clock className="w-3 h-3" />
|
||||
<span>
|
||||
{eta ? (
|
||||
<span className={eta.includes('delayed') ? 'text-red-600' : ''}>
|
||||
ETA: {eta}
|
||||
</span>
|
||||
) : (
|
||||
t('tracker.pending_start')
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Equipment & Staff */}
|
||||
{(batch.equipment_used?.length > 0 || batch.staff_assigned?.length > 0) && (
|
||||
<div className="mt-2 pt-2 border-t border-[var(--border-primary)] text-xs text-[var(--text-tertiary)]">
|
||||
{batch.equipment_used?.length > 0 && (
|
||||
<div className="mb-1">
|
||||
{t('batch.equipment')}: {batch.equipment_used.join(', ')}
|
||||
</div>
|
||||
)}
|
||||
{batch.staff_assigned?.length > 0 && (
|
||||
<div>
|
||||
{t('batch.staff')}: {batch.staff_assigned.length} {t('common.assigned')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Summary */}
|
||||
{batches.length > 0 && (
|
||||
<div className="pt-3 border-t border-[var(--border-primary)]">
|
||||
<div className="grid grid-cols-4 gap-4 text-center text-xs">
|
||||
<div>
|
||||
<p className="font-medium text-green-600">
|
||||
{batches.filter(b => b.status === ProductionStatus.COMPLETED).length}
|
||||
</p>
|
||||
<p className="text-[var(--text-tertiary)]">{t('status.completed')}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium text-blue-600">
|
||||
{batches.filter(b => b.status === ProductionStatus.IN_PROGRESS).length}
|
||||
</p>
|
||||
<p className="text-[var(--text-tertiary)]">{t('status.in_progress')}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium text-orange-600">
|
||||
{batches.filter(b => b.status === ProductionStatus.PENDING).length}
|
||||
</p>
|
||||
<p className="text-[var(--text-tertiary)]">{t('status.pending')}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium text-red-600">
|
||||
{batches.filter(b => [ProductionStatus.FAILED, ProductionStatus.CANCELLED].includes(b.status)).length}
|
||||
</p>
|
||||
<p className="text-[var(--text-tertiary)]">{t('tracker.issues')}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</AnalyticsWidget>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,305 @@
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Calendar, Clock, Wrench, AlertCircle, CheckCircle2 } from 'lucide-react';
|
||||
import { AnalyticsWidget } from '../AnalyticsWidget';
|
||||
import { Badge, Button } from '../../../../ui';
|
||||
import { useCurrentTenant } from '../../../../../stores/tenant.store';
|
||||
|
||||
interface MaintenanceTask {
|
||||
id: string;
|
||||
equipmentId: string;
|
||||
equipmentName: string;
|
||||
type: 'preventive' | 'corrective' | 'inspection';
|
||||
priority: 'low' | 'medium' | 'high' | 'urgent';
|
||||
status: 'scheduled' | 'in_progress' | 'completed' | 'overdue';
|
||||
scheduledDate: string;
|
||||
completedDate?: string;
|
||||
estimatedDuration: number; // in hours
|
||||
actualDuration?: number;
|
||||
technician?: string;
|
||||
description: string;
|
||||
cost?: number;
|
||||
}
|
||||
|
||||
export const MaintenanceScheduleWidget: React.FC = () => {
|
||||
const { t } = useTranslation('production');
|
||||
const currentTenant = useCurrentTenant();
|
||||
|
||||
// Mock maintenance data - replace with real API call
|
||||
const maintenanceTasks: MaintenanceTask[] = [
|
||||
{
|
||||
id: '1',
|
||||
equipmentId: '1',
|
||||
equipmentName: 'Horno Principal',
|
||||
type: 'preventive',
|
||||
priority: 'high',
|
||||
status: 'scheduled',
|
||||
scheduledDate: '2024-01-28',
|
||||
estimatedDuration: 4,
|
||||
description: 'Limpieza profunda y calibración de temperatura',
|
||||
cost: 150
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
equipmentId: '2',
|
||||
equipmentName: 'Mezcladora A',
|
||||
type: 'corrective',
|
||||
priority: 'urgent',
|
||||
status: 'overdue',
|
||||
scheduledDate: '2024-01-25',
|
||||
estimatedDuration: 2,
|
||||
description: 'Reparación de motor y reemplazo de correas',
|
||||
cost: 280
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
equipmentId: '3',
|
||||
equipmentName: 'Cámara de Fermentación 1',
|
||||
type: 'inspection',
|
||||
priority: 'medium',
|
||||
status: 'in_progress',
|
||||
scheduledDate: '2024-01-26',
|
||||
estimatedDuration: 1,
|
||||
technician: 'Carlos López',
|
||||
description: 'Inspección rutinaria de sistemas de control',
|
||||
cost: 80
|
||||
},
|
||||
{
|
||||
id: '4',
|
||||
equipmentId: '4',
|
||||
equipmentName: 'Empaquetadora',
|
||||
type: 'preventive',
|
||||
priority: 'medium',
|
||||
status: 'completed',
|
||||
scheduledDate: '2024-01-24',
|
||||
completedDate: '2024-01-24',
|
||||
estimatedDuration: 3,
|
||||
actualDuration: 2.5,
|
||||
technician: 'Ana García',
|
||||
description: 'Mantenimiento preventivo mensual',
|
||||
cost: 120
|
||||
},
|
||||
{
|
||||
id: '5',
|
||||
equipmentId: '1',
|
||||
equipmentName: 'Horno Principal',
|
||||
type: 'inspection',
|
||||
priority: 'low',
|
||||
status: 'scheduled',
|
||||
scheduledDate: '2024-02-05',
|
||||
estimatedDuration: 0.5,
|
||||
description: 'Inspección de seguridad semanal'
|
||||
}
|
||||
];
|
||||
|
||||
const getStatusColor = (status: MaintenanceTask['status']) => {
|
||||
switch (status) {
|
||||
case 'completed': return 'text-green-600';
|
||||
case 'in_progress': return 'text-blue-600';
|
||||
case 'scheduled': return 'text-orange-600';
|
||||
case 'overdue': return 'text-red-600';
|
||||
default: return 'text-gray-600';
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusBadgeVariant = (status: MaintenanceTask['status']) => {
|
||||
switch (status) {
|
||||
case 'completed': return 'success';
|
||||
case 'in_progress': return 'info';
|
||||
case 'scheduled': return 'warning';
|
||||
case 'overdue': return 'error';
|
||||
default: return 'default';
|
||||
}
|
||||
};
|
||||
|
||||
const getPriorityColor = (priority: MaintenanceTask['priority']) => {
|
||||
switch (priority) {
|
||||
case 'urgent': return 'text-red-600';
|
||||
case 'high': return 'text-orange-600';
|
||||
case 'medium': return 'text-blue-600';
|
||||
case 'low': return 'text-gray-600';
|
||||
default: return 'text-gray-600';
|
||||
}
|
||||
};
|
||||
|
||||
const getTypeIcon = (type: MaintenanceTask['type']) => {
|
||||
switch (type) {
|
||||
case 'preventive': return Calendar;
|
||||
case 'corrective': return Wrench;
|
||||
case 'inspection': return CheckCircle2;
|
||||
default: return Wrench;
|
||||
}
|
||||
};
|
||||
|
||||
const formatDate = (dateString: string) => {
|
||||
return new Date(dateString).toLocaleDateString('es-ES', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric'
|
||||
});
|
||||
};
|
||||
|
||||
const scheduledTasks = maintenanceTasks.filter(t => t.status === 'scheduled');
|
||||
const overdueTasks = maintenanceTasks.filter(t => t.status === 'overdue');
|
||||
const inProgressTasks = maintenanceTasks.filter(t => t.status === 'in_progress');
|
||||
const completedTasks = maintenanceTasks.filter(t => t.status === 'completed');
|
||||
|
||||
const totalCost = maintenanceTasks.reduce((sum, task) => sum + (task.cost || 0), 0);
|
||||
const avgDuration = maintenanceTasks.reduce((sum, task) => sum + task.estimatedDuration, 0) / maintenanceTasks.length;
|
||||
|
||||
return (
|
||||
<AnalyticsWidget
|
||||
title={t('equipment.maintenance.title')}
|
||||
subtitle={t('equipment.maintenance.subtitle')}
|
||||
icon={Calendar}
|
||||
actions={
|
||||
<div className="flex space-x-2">
|
||||
<Button variant="outline" size="sm">
|
||||
<Calendar className="w-4 h-4 mr-1" />
|
||||
{t('equipment.actions.schedule_maintenance')}
|
||||
</Button>
|
||||
<Button variant="primary" size="sm">
|
||||
<Wrench className="w-4 h-4 mr-1" />
|
||||
{t('equipment.actions.add_task')}
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className="space-y-6">
|
||||
{/* Maintenance Overview Stats */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<div className="text-center p-4 bg-[var(--bg-secondary)] rounded-lg">
|
||||
<Calendar className="w-8 h-8 mx-auto text-orange-600 mb-2" />
|
||||
<p className="text-2xl font-bold text-[var(--text-primary)]">{scheduledTasks.length}</p>
|
||||
<p className="text-sm text-[var(--text-secondary)]">{t('equipment.maintenance.scheduled')}</p>
|
||||
</div>
|
||||
<div className="text-center p-4 bg-[var(--bg-secondary)] rounded-lg">
|
||||
<AlertCircle className="w-8 h-8 mx-auto text-red-600 mb-2" />
|
||||
<p className="text-2xl font-bold text-[var(--text-primary)]">{overdueTasks.length}</p>
|
||||
<p className="text-sm text-[var(--text-secondary)]">{t('equipment.maintenance.overdue')}</p>
|
||||
</div>
|
||||
<div className="text-center p-4 bg-[var(--bg-secondary)] rounded-lg">
|
||||
<Clock className="w-8 h-8 mx-auto text-blue-600 mb-2" />
|
||||
<p className="text-2xl font-bold text-[var(--text-primary)]">{avgDuration.toFixed(1)}h</p>
|
||||
<p className="text-sm text-[var(--text-secondary)]">{t('equipment.maintenance.avg_duration')}</p>
|
||||
</div>
|
||||
<div className="text-center p-4 bg-[var(--bg-secondary)] rounded-lg">
|
||||
<div className="w-8 h-8 mx-auto bg-green-100 dark:bg-green-900/20 rounded-full flex items-center justify-center mb-2">
|
||||
<span className="text-green-600 font-bold text-sm">€</span>
|
||||
</div>
|
||||
<p className="text-2xl font-bold text-[var(--text-primary)]">€{totalCost}</p>
|
||||
<p className="text-sm text-[var(--text-secondary)]">{t('equipment.maintenance.total_cost')}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Priority Tasks */}
|
||||
{overdueTasks.length > 0 && (
|
||||
<div className="p-4 bg-red-50 dark:bg-red-900/20 rounded-lg">
|
||||
<div className="flex items-start space-x-2">
|
||||
<AlertCircle className="w-5 h-5 text-red-600 mt-0.5" />
|
||||
<div>
|
||||
<p className="text-sm font-medium text-red-600">
|
||||
{t('equipment.maintenance.overdue_tasks')} ({overdueTasks.length})
|
||||
</p>
|
||||
<p className="text-xs text-[var(--text-secondary)] mt-1">
|
||||
{t('equipment.maintenance.immediate_attention_required')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Maintenance Tasks List */}
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-[var(--text-primary)] mb-3 flex items-center">
|
||||
<Wrench className="w-4 h-4 mr-2" />
|
||||
{t('equipment.maintenance.tasks')} ({maintenanceTasks.length})
|
||||
</h4>
|
||||
<div className="space-y-3">
|
||||
{maintenanceTasks.map((task) => {
|
||||
const TypeIcon = getTypeIcon(task.type);
|
||||
return (
|
||||
<div key={task.id} className="p-4 bg-[var(--bg-secondary)] rounded-lg hover:bg-[var(--bg-tertiary)] transition-colors">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-start space-x-3">
|
||||
<TypeIcon className={`w-5 h-5 mt-1 ${getStatusColor(task.status)}`} />
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center space-x-2 mb-1">
|
||||
<p className="font-medium text-[var(--text-primary)]">{task.equipmentName}</p>
|
||||
<span className={`text-xs font-medium ${getPriorityColor(task.priority)}`}>
|
||||
{t(`priority.${task.priority}`)}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm text-[var(--text-secondary)] mb-2">{task.description}</p>
|
||||
<div className="flex items-center space-x-4 text-xs text-[var(--text-secondary)]">
|
||||
<span>{t('equipment.maintenance.scheduled')}: {formatDate(task.scheduledDate)}</span>
|
||||
<span>{t('equipment.maintenance.duration')}: {task.estimatedDuration}h</span>
|
||||
{task.cost && <span>{t('equipment.maintenance.cost')}: €{task.cost}</span>}
|
||||
{task.technician && <span>{t('equipment.maintenance.technician')}: {task.technician}</span>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col items-end space-y-2">
|
||||
<Badge variant={getStatusBadgeVariant(task.status)}>
|
||||
{t(`equipment.maintenance.status.${task.status}`)}
|
||||
</Badge>
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{t(`equipment.maintenance.type.${task.type}`)}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Weekly Schedule Preview */}
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-[var(--text-primary)] mb-3 flex items-center">
|
||||
<Calendar className="w-4 h-4 mr-2" />
|
||||
{t('equipment.maintenance.this_week')}
|
||||
</h4>
|
||||
<div className="grid grid-cols-7 gap-1">
|
||||
{Array.from({ length: 7 }, (_, i) => {
|
||||
const date = new Date();
|
||||
date.setDate(date.getDate() + i);
|
||||
const dateStr = date.toISOString().split('T')[0];
|
||||
const tasksForDay = maintenanceTasks.filter(task => task.scheduledDate === dateStr);
|
||||
|
||||
return (
|
||||
<div key={i} className="p-2 bg-[var(--bg-secondary)] rounded text-center">
|
||||
<p className="text-xs text-[var(--text-secondary)] mb-1">
|
||||
{date.toLocaleDateString('es-ES', { weekday: 'short' })}
|
||||
</p>
|
||||
<p className="text-sm font-medium text-[var(--text-primary)]">
|
||||
{date.getDate()}
|
||||
</p>
|
||||
{tasksForDay.length > 0 && (
|
||||
<div className="w-2 h-2 bg-orange-500 rounded-full mx-auto mt-1"></div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Maintenance Insights */}
|
||||
<div className="p-4 bg-blue-50 dark:bg-blue-900/20 rounded-lg">
|
||||
<div className="flex items-start space-x-2">
|
||||
<CheckCircle2 className="w-5 h-5 text-blue-600 mt-0.5" />
|
||||
<div>
|
||||
<p className="text-sm font-medium text-blue-600">
|
||||
{t('equipment.maintenance.insights.title')}
|
||||
</p>
|
||||
<p className="text-xs text-[var(--text-secondary)] mt-1">
|
||||
{completedTasks.length} {t('equipment.maintenance.insights.completed_this_month')},
|
||||
{scheduledTasks.length} {t('equipment.maintenance.insights.scheduled_next_week')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</AnalyticsWidget>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,195 @@
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Clock, TrendingUp, TrendingDown, Target } from 'lucide-react';
|
||||
import { AnalyticsWidget } from '../AnalyticsWidget';
|
||||
import { AnalyticsChart, ChartSeries } from '../AnalyticsChart';
|
||||
import { Button } from '../../../../ui';
|
||||
import { useProductionDashboard } from '../../../../../api/hooks/production';
|
||||
import { useCurrentTenant } from '../../../../../stores/tenant.store';
|
||||
|
||||
export const OnTimeCompletionWidget: React.FC = () => {
|
||||
const { t } = useTranslation('production');
|
||||
const currentTenant = useCurrentTenant();
|
||||
const tenantId = currentTenant?.id || '';
|
||||
|
||||
const { data: dashboard, isLoading, error } = useProductionDashboard(tenantId);
|
||||
|
||||
// Mock historical data for the chart (in a real implementation, this would come from an analytics API)
|
||||
const getHistoricalCompletionData = (): ChartSeries[] => {
|
||||
const mockData = [
|
||||
{ x: 'Lun', y: 92, label: 'Lunes' },
|
||||
{ x: 'Mar', y: 89, label: 'Martes' },
|
||||
{ x: 'Mié', y: 95, label: 'Miércoles' },
|
||||
{ x: 'Jue', y: 87, label: 'Jueves' },
|
||||
{ x: 'Vie', y: 94, label: 'Viernes' },
|
||||
{ x: 'Sáb', y: 98, label: 'Sábado' },
|
||||
{ x: 'Dom', y: dashboard?.on_time_completion_rate || 91, label: 'Domingo' },
|
||||
];
|
||||
|
||||
return [
|
||||
{
|
||||
id: 'completion-rate',
|
||||
name: t('stats.on_time_completion'),
|
||||
type: 'line',
|
||||
color: '#10b981',
|
||||
data: mockData
|
||||
}
|
||||
];
|
||||
};
|
||||
|
||||
const currentRate = dashboard?.on_time_completion_rate || 0;
|
||||
const targetRate = 95; // Target completion rate
|
||||
const weekAverage = 93; // Mock week average
|
||||
const trend = currentRate - weekAverage;
|
||||
|
||||
const getCompletionRateStatus = (rate: number) => {
|
||||
if (rate >= 95) return { color: 'text-green-600', icon: TrendingUp, status: 'excellent' };
|
||||
if (rate >= 90) return { color: 'text-blue-600', icon: TrendingUp, status: 'good' };
|
||||
if (rate >= 85) return { color: 'text-orange-600', icon: TrendingDown, status: 'warning' };
|
||||
return { color: 'text-red-600', icon: TrendingDown, status: 'critical' };
|
||||
};
|
||||
|
||||
const rateStatus = getCompletionRateStatus(currentRate);
|
||||
const RateIcon = rateStatus.icon;
|
||||
|
||||
const getPerformanceInsight = () => {
|
||||
if (currentRate >= targetRate) {
|
||||
return {
|
||||
message: t('insights.on_time_excellent'),
|
||||
color: 'text-green-600',
|
||||
bgColor: 'bg-green-50 dark:bg-green-900/20'
|
||||
};
|
||||
} else if (currentRate >= 90) {
|
||||
return {
|
||||
message: t('insights.on_time_good_room_improvement'),
|
||||
color: 'text-blue-600',
|
||||
bgColor: 'bg-blue-50 dark:bg-blue-900/20'
|
||||
};
|
||||
} else if (currentRate >= 85) {
|
||||
return {
|
||||
message: t('insights.on_time_needs_attention'),
|
||||
color: 'text-orange-600',
|
||||
bgColor: 'bg-orange-50 dark:bg-orange-900/20'
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
message: t('insights.on_time_critical_delays'),
|
||||
color: 'text-red-600',
|
||||
bgColor: 'bg-red-50 dark:bg-red-900/20'
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const insight = getPerformanceInsight();
|
||||
|
||||
return (
|
||||
<AnalyticsWidget
|
||||
title={t('stats.on_time_completion_rate')}
|
||||
subtitle={t('insights.on_time_vs_planned')}
|
||||
icon={Clock}
|
||||
loading={isLoading}
|
||||
error={error?.message}
|
||||
actions={
|
||||
<Button variant="outline" size="sm">
|
||||
<Target className="w-4 h-4 mr-1" />
|
||||
{t('actions.analyze_delays')}
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
{/* Current Rate Display */}
|
||||
<div className="text-center">
|
||||
<div className="flex items-center justify-center space-x-2 mb-2">
|
||||
<RateIcon className={`w-6 h-6 ${rateStatus.color}`} />
|
||||
<span className="text-3xl font-bold text-[var(--text-primary)]">
|
||||
{currentRate.toFixed(1)}%
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-center space-x-4 text-sm">
|
||||
<div className={`flex items-center space-x-1 ${trend >= 0 ? 'text-green-600' : 'text-red-600'}`}>
|
||||
{trend >= 0 ? (
|
||||
<TrendingUp className="w-4 h-4" />
|
||||
) : (
|
||||
<TrendingDown className="w-4 h-4" />
|
||||
)}
|
||||
<span>{Math.abs(trend).toFixed(1)}% {t('insights.vs_week_avg')}</span>
|
||||
</div>
|
||||
<div className="text-[var(--text-secondary)]">
|
||||
{t('insights.target')}: {targetRate}%
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Progress towards target */}
|
||||
<div className="p-3 bg-[var(--bg-secondary)] rounded-lg">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-sm text-[var(--text-secondary)]">{t('insights.progress_to_target')}</span>
|
||||
<span className="text-sm font-medium text-[var(--text-primary)]">
|
||||
{Math.min(currentRate, targetRate).toFixed(1)}% / {targetRate}%
|
||||
</span>
|
||||
</div>
|
||||
<div className="w-full bg-[var(--bg-tertiary)] rounded-full h-2">
|
||||
<div
|
||||
className={`h-2 rounded-full transition-all ${
|
||||
currentRate >= targetRate ? 'bg-green-500' :
|
||||
currentRate >= 90 ? 'bg-blue-500' :
|
||||
currentRate >= 85 ? 'bg-orange-500' : 'bg-red-500'
|
||||
}`}
|
||||
style={{ width: `${Math.min((currentRate / targetRate) * 100, 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Weekly Trend Chart */}
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-[var(--text-primary)] mb-3">
|
||||
{t('insights.weekly_trend')}
|
||||
</h4>
|
||||
<AnalyticsChart
|
||||
series={getHistoricalCompletionData()}
|
||||
height={150}
|
||||
showLegend={false}
|
||||
showGrid={true}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Performance Insight */}
|
||||
<div className={`p-3 rounded-lg ${insight.bgColor}`}>
|
||||
<div className="flex items-start space-x-2">
|
||||
<Clock className={`w-4 h-4 mt-0.5 ${insight.color}`} />
|
||||
<div>
|
||||
<p className={`text-sm font-medium ${insight.color}`}>
|
||||
{t('insights.performance_insight')}
|
||||
</p>
|
||||
<p className="text-sm text-[var(--text-secondary)] mt-1">
|
||||
{insight.message}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Key Metrics */}
|
||||
<div className="grid grid-cols-3 gap-3 text-center">
|
||||
<div className="p-3 bg-[var(--bg-secondary)] rounded-lg">
|
||||
<p className="text-lg font-bold text-[var(--text-primary)]">
|
||||
{Math.round(currentRate * 0.85)}
|
||||
</p>
|
||||
<p className="text-xs text-[var(--text-secondary)]">{t('insights.batches_on_time')}</p>
|
||||
</div>
|
||||
<div className="p-3 bg-[var(--bg-secondary)] rounded-lg">
|
||||
<p className="text-lg font-bold text-[var(--text-primary)]">
|
||||
{Math.round((100 - currentRate) * 0.15)}
|
||||
</p>
|
||||
<p className="text-xs text-[var(--text-secondary)]">{t('insights.batches_delayed')}</p>
|
||||
</div>
|
||||
<div className="p-3 bg-[var(--bg-secondary)] rounded-lg">
|
||||
<p className="text-lg font-bold text-[var(--text-primary)]">
|
||||
{Math.round(12 * (100 - currentRate) / 100)}
|
||||
</p>
|
||||
<p className="text-xs text-[var(--text-secondary)]">{t('insights.avg_delay_minutes')}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</AnalyticsWidget>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,437 @@
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Brain, AlertTriangle, TrendingDown, Calendar, Wrench, Target } from 'lucide-react';
|
||||
import { AnalyticsWidget } from '../AnalyticsWidget';
|
||||
import { AnalyticsChart, ChartSeries } from '../AnalyticsChart';
|
||||
import { Badge, Button } from '../../../../ui';
|
||||
import { useCurrentTenant } from '../../../../../stores/tenant.store';
|
||||
|
||||
interface PredictiveMaintenanceAlert {
|
||||
id: string;
|
||||
equipmentId: string;
|
||||
equipmentName: string;
|
||||
equipmentType: 'oven' | 'mixer' | 'proofer' | 'packaging';
|
||||
alertType: 'wear_prediction' | 'failure_risk' | 'performance_degradation' | 'component_replacement';
|
||||
severity: 'low' | 'medium' | 'high' | 'critical';
|
||||
confidence: number; // percentage
|
||||
predictedFailureDate: string;
|
||||
currentCondition: number; // percentage (100% = perfect, 0% = failed)
|
||||
degradationRate: number; // percentage per week
|
||||
affectedComponents: string[];
|
||||
recommendedActions: string[];
|
||||
estimatedCost: number;
|
||||
potentialDowntime: number; // hours
|
||||
riskScore: number; // 0-100
|
||||
dataPoints: Array<{
|
||||
date: string;
|
||||
condition: number;
|
||||
vibration?: number;
|
||||
temperature?: number;
|
||||
efficiency?: number;
|
||||
}>;
|
||||
}
|
||||
|
||||
export const PredictiveMaintenanceWidget: React.FC = () => {
|
||||
const { t } = useTranslation('production');
|
||||
const currentTenant = useCurrentTenant();
|
||||
|
||||
// Mock predictive maintenance data - replace with real ML API call
|
||||
const maintenanceAlerts: PredictiveMaintenanceAlert[] = [
|
||||
{
|
||||
id: '1',
|
||||
equipmentId: '2',
|
||||
equipmentName: 'Mezcladora A',
|
||||
equipmentType: 'mixer',
|
||||
alertType: 'wear_prediction',
|
||||
severity: 'high',
|
||||
confidence: 94,
|
||||
predictedFailureDate: '2024-02-15',
|
||||
currentCondition: 78,
|
||||
degradationRate: 3.2,
|
||||
affectedComponents: ['Motor principal', 'Correas de transmisión', 'Rodamientos'],
|
||||
recommendedActions: [
|
||||
'Reemplazar correas de transmisión',
|
||||
'Lubricar rodamientos',
|
||||
'Inspeccionar motor'
|
||||
],
|
||||
estimatedCost: 280,
|
||||
potentialDowntime: 4,
|
||||
riskScore: 85,
|
||||
dataPoints: [
|
||||
{ date: '2024-01-01', condition: 95, vibration: 1.2, temperature: 35, efficiency: 94 },
|
||||
{ date: '2024-01-07', condition: 91, vibration: 1.5, temperature: 37, efficiency: 92 },
|
||||
{ date: '2024-01-14', condition: 87, vibration: 1.8, temperature: 39, efficiency: 89 },
|
||||
{ date: '2024-01-21', condition: 83, vibration: 2.1, temperature: 41, efficiency: 87 },
|
||||
{ date: '2024-01-28', condition: 78, vibration: 2.4, temperature: 43, efficiency: 85 }
|
||||
]
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
equipmentId: '1',
|
||||
equipmentName: 'Horno Principal',
|
||||
equipmentType: 'oven',
|
||||
alertType: 'performance_degradation',
|
||||
severity: 'medium',
|
||||
confidence: 87,
|
||||
predictedFailureDate: '2024-03-10',
|
||||
currentCondition: 89,
|
||||
degradationRate: 1.8,
|
||||
affectedComponents: ['Sistema de calefacción', 'Sensores de temperatura'],
|
||||
recommendedActions: [
|
||||
'Calibrar sensores de temperatura',
|
||||
'Limpiar sistema de calefacción',
|
||||
'Verificar aislamiento térmico'
|
||||
],
|
||||
estimatedCost: 150,
|
||||
potentialDowntime: 2,
|
||||
riskScore: 65,
|
||||
dataPoints: [
|
||||
{ date: '2024-01-01', condition: 98, temperature: 180, efficiency: 96 },
|
||||
{ date: '2024-01-07', condition: 95, temperature: 178, efficiency: 95 },
|
||||
{ date: '2024-01-14', condition: 93, temperature: 176, efficiency: 94 },
|
||||
{ date: '2024-01-21', condition: 91, temperature: 174, efficiency: 93 },
|
||||
{ date: '2024-01-28', condition: 89, temperature: 172, efficiency: 92 }
|
||||
]
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
equipmentId: '4',
|
||||
equipmentName: 'Empaquetadora',
|
||||
equipmentType: 'packaging',
|
||||
alertType: 'component_replacement',
|
||||
severity: 'low',
|
||||
confidence: 76,
|
||||
predictedFailureDate: '2024-04-20',
|
||||
currentCondition: 92,
|
||||
degradationRate: 1.1,
|
||||
affectedComponents: ['Cinta transportadora', 'Sistema de sellado'],
|
||||
recommendedActions: [
|
||||
'Inspeccionar cinta transportadora',
|
||||
'Ajustar sistema de sellado',
|
||||
'Reemplazar filtros'
|
||||
],
|
||||
estimatedCost: 120,
|
||||
potentialDowntime: 1.5,
|
||||
riskScore: 35,
|
||||
dataPoints: [
|
||||
{ date: '2024-01-01', condition: 98, efficiency: 97 },
|
||||
{ date: '2024-01-07', condition: 96, efficiency: 96 },
|
||||
{ date: '2024-01-14', condition: 95, efficiency: 95 },
|
||||
{ date: '2024-01-21', condition: 94, efficiency: 94 },
|
||||
{ date: '2024-01-28', condition: 92, efficiency: 93 }
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
const getSeverityColor = (severity: PredictiveMaintenanceAlert['severity']) => {
|
||||
switch (severity) {
|
||||
case 'critical': return 'text-red-600';
|
||||
case 'high': return 'text-orange-600';
|
||||
case 'medium': return 'text-yellow-600';
|
||||
case 'low': return 'text-blue-600';
|
||||
default: return 'text-gray-600';
|
||||
}
|
||||
};
|
||||
|
||||
const getSeverityBadgeVariant = (severity: PredictiveMaintenanceAlert['severity']) => {
|
||||
switch (severity) {
|
||||
case 'critical': return 'error';
|
||||
case 'high': return 'warning';
|
||||
case 'medium': return 'warning';
|
||||
case 'low': return 'info';
|
||||
default: return 'default';
|
||||
}
|
||||
};
|
||||
|
||||
const getAlertTypeIcon = (type: PredictiveMaintenanceAlert['alertType']) => {
|
||||
switch (type) {
|
||||
case 'wear_prediction': return TrendingDown;
|
||||
case 'failure_risk': return AlertTriangle;
|
||||
case 'performance_degradation': return Target;
|
||||
case 'component_replacement': return Wrench;
|
||||
default: return AlertTriangle;
|
||||
}
|
||||
};
|
||||
|
||||
const formatDate = (dateString: string) => {
|
||||
return new Date(dateString).toLocaleDateString('es-ES', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric'
|
||||
});
|
||||
};
|
||||
|
||||
const getDaysUntilFailure = (failureDate: string) => {
|
||||
const today = new Date();
|
||||
const failure = new Date(failureDate);
|
||||
const diffTime = failure.getTime() - today.getTime();
|
||||
const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24));
|
||||
return diffDays;
|
||||
};
|
||||
|
||||
const criticalAlerts = maintenanceAlerts.filter(alert => alert.severity === 'critical');
|
||||
const highRiskAlerts = maintenanceAlerts.filter(alert => alert.riskScore > 70);
|
||||
const totalEstimatedCost = maintenanceAlerts.reduce((sum, alert) => sum + alert.estimatedCost, 0);
|
||||
const totalPotentialDowntime = maintenanceAlerts.reduce((sum, alert) => sum + alert.potentialDowntime, 0);
|
||||
const avgConfidence = maintenanceAlerts.reduce((sum, alert) => sum + alert.confidence, 0) / maintenanceAlerts.length;
|
||||
|
||||
// Create equipment condition trend chart
|
||||
const getConditionTrendChartData = (): ChartSeries[] => {
|
||||
return maintenanceAlerts.map((alert, index) => ({
|
||||
id: `condition-${alert.equipmentId}`,
|
||||
name: alert.equipmentName,
|
||||
type: 'line',
|
||||
color: ['#ef4444', '#f97316', '#eab308'][index % 3],
|
||||
data: alert.dataPoints.map(point => ({
|
||||
x: new Date(point.date).toLocaleDateString('es-ES', { month: 'short', day: 'numeric' }),
|
||||
y: point.condition,
|
||||
label: `${point.condition}%`
|
||||
}))
|
||||
}));
|
||||
};
|
||||
|
||||
// Create risk score distribution chart
|
||||
const getRiskDistributionChartData = (): ChartSeries[] => {
|
||||
const lowRisk = maintenanceAlerts.filter(a => a.riskScore <= 40).length;
|
||||
const mediumRisk = maintenanceAlerts.filter(a => a.riskScore > 40 && a.riskScore <= 70).length;
|
||||
const highRisk = maintenanceAlerts.filter(a => a.riskScore > 70).length;
|
||||
|
||||
return [
|
||||
{
|
||||
id: 'risk-distribution',
|
||||
name: 'Risk Distribution',
|
||||
type: 'doughnut',
|
||||
color: '#3b82f6',
|
||||
data: [
|
||||
{ x: t('ai.risk.low'), y: lowRisk, label: `${t('ai.risk.low')}: ${lowRisk}` },
|
||||
{ x: t('ai.risk.medium'), y: mediumRisk, label: `${t('ai.risk.medium')}: ${mediumRisk}` },
|
||||
{ x: t('ai.risk.high'), y: highRisk, label: `${t('ai.risk.high')}: ${highRisk}` }
|
||||
].filter(item => item.y > 0)
|
||||
}
|
||||
];
|
||||
};
|
||||
|
||||
return (
|
||||
<AnalyticsWidget
|
||||
title={t('ai.predictive_maintenance.title')}
|
||||
subtitle={t('ai.predictive_maintenance.subtitle')}
|
||||
icon={Brain}
|
||||
actions={
|
||||
<div className="flex space-x-2">
|
||||
<Button variant="outline" size="sm">
|
||||
<Calendar className="w-4 h-4 mr-1" />
|
||||
{t('ai.predictive_maintenance.schedule_all')}
|
||||
</Button>
|
||||
<Button variant="primary" size="sm">
|
||||
<Brain className="w-4 h-4 mr-1" />
|
||||
{t('ai.predictive_maintenance.retrain_model')}
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className="space-y-6">
|
||||
{/* Predictive Maintenance Overview Stats */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<div className="text-center p-4 bg-[var(--bg-secondary)] rounded-lg">
|
||||
<AlertTriangle className="w-8 h-8 mx-auto text-red-600 mb-2" />
|
||||
<p className="text-2xl font-bold text-[var(--text-primary)]">{highRiskAlerts.length}</p>
|
||||
<p className="text-sm text-[var(--text-secondary)]">{t('ai.predictive_maintenance.high_risk')}</p>
|
||||
</div>
|
||||
<div className="text-center p-4 bg-[var(--bg-secondary)] rounded-lg">
|
||||
<div className="w-8 h-8 mx-auto bg-orange-100 dark:bg-orange-900/20 rounded-full flex items-center justify-center mb-2">
|
||||
<span className="text-orange-600 font-bold text-sm">€</span>
|
||||
</div>
|
||||
<p className="text-2xl font-bold text-[var(--text-primary)]">€{totalEstimatedCost}</p>
|
||||
<p className="text-sm text-[var(--text-secondary)]">{t('ai.predictive_maintenance.estimated_cost')}</p>
|
||||
</div>
|
||||
<div className="text-center p-4 bg-[var(--bg-secondary)] rounded-lg">
|
||||
<div className="w-8 h-8 mx-auto bg-red-100 dark:bg-red-900/20 rounded-full flex items-center justify-center mb-2">
|
||||
<span className="text-red-600 font-bold text-xs">h</span>
|
||||
</div>
|
||||
<p className="text-2xl font-bold text-[var(--text-primary)]">{totalPotentialDowntime}</p>
|
||||
<p className="text-sm text-[var(--text-secondary)]">{t('ai.predictive_maintenance.potential_downtime')}</p>
|
||||
</div>
|
||||
<div className="text-center p-4 bg-[var(--bg-secondary)] rounded-lg">
|
||||
<div className="w-8 h-8 mx-auto bg-blue-100 dark:bg-blue-900/20 rounded-full flex items-center justify-center mb-2">
|
||||
<span className="text-blue-600 font-bold text-sm">%</span>
|
||||
</div>
|
||||
<p className="text-2xl font-bold text-[var(--text-primary)]">{avgConfidence.toFixed(0)}%</p>
|
||||
<p className="text-sm text-[var(--text-secondary)]">{t('ai.predictive_maintenance.avg_confidence')}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Critical Alerts */}
|
||||
{highRiskAlerts.length > 0 && (
|
||||
<div className="p-4 bg-red-50 dark:bg-red-900/20 rounded-lg">
|
||||
<div className="flex items-start space-x-2">
|
||||
<AlertTriangle className="w-5 h-5 text-red-600 mt-0.5" />
|
||||
<div>
|
||||
<p className="text-sm font-medium text-red-600">
|
||||
{t('ai.predictive_maintenance.high_risk_equipment')} ({highRiskAlerts.length})
|
||||
</p>
|
||||
<p className="text-xs text-[var(--text-secondary)] mt-1">
|
||||
{t('ai.predictive_maintenance.immediate_attention_required')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Maintenance Predictions */}
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-[var(--text-primary)] mb-3 flex items-center">
|
||||
<Brain className="w-4 h-4 mr-2" />
|
||||
{t('ai.predictive_maintenance.predictions')} ({maintenanceAlerts.length})
|
||||
</h4>
|
||||
<div className="space-y-4">
|
||||
{maintenanceAlerts.map((alert) => {
|
||||
const AlertTypeIcon = getAlertTypeIcon(alert.alertType);
|
||||
const daysUntilFailure = getDaysUntilFailure(alert.predictedFailureDate);
|
||||
|
||||
return (
|
||||
<div key={alert.id} className="p-4 bg-[var(--bg-secondary)] rounded-lg">
|
||||
<div className="flex items-start justify-between mb-3">
|
||||
<div className="flex items-start space-x-3">
|
||||
<AlertTypeIcon className={`w-5 h-5 mt-1 ${getSeverityColor(alert.severity)}`} />
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center space-x-2 mb-1">
|
||||
<p className="font-medium text-[var(--text-primary)]">{alert.equipmentName}</p>
|
||||
<Badge variant={getSeverityBadgeVariant(alert.severity)}>
|
||||
{t(`ai.severity.${alert.severity}`)}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="flex items-center space-x-4 text-xs text-[var(--text-secondary)] mb-2">
|
||||
<span>{t('ai.predictive_maintenance.confidence')}: {alert.confidence}%</span>
|
||||
<span>{t('ai.predictive_maintenance.risk_score')}: {alert.riskScore}/100</span>
|
||||
<span>{t('ai.predictive_maintenance.days_until_failure')}: {daysUntilFailure}</span>
|
||||
</div>
|
||||
<p className="text-sm text-[var(--text-secondary)] mb-2">
|
||||
{t(`ai.predictive_maintenance.alert_type.${alert.alertType}`)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<div className="text-lg font-bold text-[var(--text-primary)]">{alert.currentCondition}%</div>
|
||||
<div className="text-xs text-[var(--text-secondary)]">{t('ai.predictive_maintenance.condition')}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Condition Progress Bar */}
|
||||
<div className="mb-3">
|
||||
<div className="flex justify-between text-xs text-[var(--text-secondary)] mb-1">
|
||||
<span>{t('ai.predictive_maintenance.current_condition')}</span>
|
||||
<span>{alert.currentCondition}%</span>
|
||||
</div>
|
||||
<div className="w-full bg-[var(--bg-tertiary)] rounded-full h-2">
|
||||
<div
|
||||
className={`h-2 rounded-full transition-all duration-300 ${
|
||||
alert.currentCondition > 80 ? 'bg-green-500' :
|
||||
alert.currentCondition > 60 ? 'bg-yellow-500' :
|
||||
alert.currentCondition > 40 ? 'bg-orange-500' : 'bg-red-500'
|
||||
}`}
|
||||
style={{ width: `${alert.currentCondition}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Affected Components */}
|
||||
<div className="mb-3">
|
||||
<p className="text-xs font-medium text-[var(--text-primary)] mb-1">
|
||||
{t('ai.predictive_maintenance.affected_components')}:
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{alert.affectedComponents.map((component, index) => (
|
||||
<Badge key={index} variant="outline" className="text-xs">
|
||||
{component}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Recommended Actions */}
|
||||
<div className="mb-3">
|
||||
<p className="text-xs font-medium text-[var(--text-primary)] mb-1">
|
||||
{t('ai.predictive_maintenance.recommended_actions')}:
|
||||
</p>
|
||||
<ul className="text-xs text-[var(--text-secondary)] space-y-1">
|
||||
{alert.recommendedActions.map((action, index) => (
|
||||
<li key={index}>• {action}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{/* Cost and Downtime */}
|
||||
<div className="flex items-center justify-between p-3 bg-[var(--bg-tertiary)] rounded-lg">
|
||||
<div className="flex items-center space-x-4 text-xs">
|
||||
<span className="flex items-center space-x-1">
|
||||
<span className="w-2 h-2 bg-orange-500 rounded-full"></span>
|
||||
<span>{t('ai.predictive_maintenance.estimated_cost')}: €{alert.estimatedCost}</span>
|
||||
</span>
|
||||
<span className="flex items-center space-x-1">
|
||||
<span className="w-2 h-2 bg-red-500 rounded-full"></span>
|
||||
<span>{t('ai.predictive_maintenance.potential_downtime')}: {alert.potentialDowntime}h</span>
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex space-x-2">
|
||||
<Button variant="outline" size="sm">
|
||||
{t('ai.predictive_maintenance.schedule')}
|
||||
</Button>
|
||||
<Button variant="primary" size="sm">
|
||||
{t('ai.predictive_maintenance.details')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Charts */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{/* Equipment Condition Trends */}
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-[var(--text-primary)] mb-3">
|
||||
{t('ai.predictive_maintenance.condition_trends')}
|
||||
</h4>
|
||||
<AnalyticsChart
|
||||
series={getConditionTrendChartData()}
|
||||
height={200}
|
||||
showLegend={true}
|
||||
showGrid={true}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Risk Distribution */}
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-[var(--text-primary)] mb-3">
|
||||
{t('ai.predictive_maintenance.risk_distribution')}
|
||||
</h4>
|
||||
<AnalyticsChart
|
||||
series={getRiskDistributionChartData()}
|
||||
height={200}
|
||||
showLegend={true}
|
||||
showGrid={false}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ML Model Status */}
|
||||
<div className="p-4 bg-blue-50 dark:bg-blue-900/20 rounded-lg">
|
||||
<div className="flex items-start space-x-2">
|
||||
<Brain className="w-5 h-5 text-blue-600 mt-0.5" />
|
||||
<div>
|
||||
<p className="text-sm font-medium text-blue-600">
|
||||
{t('ai.predictive_maintenance.model_status')}
|
||||
</p>
|
||||
<p className="text-xs text-[var(--text-secondary)] mt-1">
|
||||
{t('ai.predictive_maintenance.last_training')}: {t('ai.predictive_maintenance.yesterday')}.
|
||||
{t('ai.predictive_maintenance.accuracy')}: 94%. {t('ai.predictive_maintenance.next_training')}: {t('ai.predictive_maintenance.in_7_days')}.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</AnalyticsWidget>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,257 @@
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Star, TrendingUp, TrendingDown, Award } from 'lucide-react';
|
||||
import { AnalyticsWidget } from '../AnalyticsWidget';
|
||||
import { AnalyticsChart, ChartSeries } from '../AnalyticsChart';
|
||||
import { Button } from '../../../../ui';
|
||||
import { useActiveBatches } from '../../../../../api/hooks/production';
|
||||
import { useCurrentTenant } from '../../../../../stores/tenant.store';
|
||||
|
||||
export const QualityScoreTrendsWidget: React.FC = () => {
|
||||
const { t } = useTranslation('production');
|
||||
const currentTenant = useCurrentTenant();
|
||||
const tenantId = currentTenant?.id || '';
|
||||
|
||||
const { data: batchesData, isLoading, error } = useActiveBatches(tenantId);
|
||||
const batches = batchesData?.batches || [];
|
||||
|
||||
// Calculate quality score data
|
||||
const getQualityData = () => {
|
||||
const qualityScores = batches
|
||||
.filter(batch => batch.quality_score)
|
||||
.map(batch => batch.quality_score!);
|
||||
|
||||
if (qualityScores.length === 0) {
|
||||
return {
|
||||
averageScore: 0,
|
||||
totalChecks: 0,
|
||||
passRate: 0,
|
||||
trend: 0
|
||||
};
|
||||
}
|
||||
|
||||
const averageScore = qualityScores.reduce((sum, score) => sum + score, 0) / qualityScores.length;
|
||||
const totalChecks = qualityScores.length;
|
||||
const passRate = (qualityScores.filter(score => score >= 7).length / totalChecks) * 100;
|
||||
|
||||
// Mock trend calculation (would be compared with previous period in real implementation)
|
||||
const trend = 2.3; // +2.3 points vs last week
|
||||
|
||||
return {
|
||||
averageScore,
|
||||
totalChecks,
|
||||
passRate,
|
||||
trend
|
||||
};
|
||||
};
|
||||
|
||||
const qualityData = getQualityData();
|
||||
|
||||
// Create daily quality trend chart
|
||||
const getQualityTrendChartData = (): ChartSeries[] => {
|
||||
const days = ['Lun', 'Mar', 'Mié', 'Jue', 'Vie', 'Sáb', 'Dom'];
|
||||
const scores = days.map((day, index) => ({
|
||||
x: day,
|
||||
y: Math.max(6, 8.5 + Math.sin(index) * 0.8 + (Math.random() - 0.5) * 0.5),
|
||||
label: day
|
||||
}));
|
||||
|
||||
return [
|
||||
{
|
||||
id: 'quality-trend',
|
||||
name: t('quality.daily_quality_score'),
|
||||
type: 'line',
|
||||
color: '#16a34a',
|
||||
data: scores
|
||||
}
|
||||
];
|
||||
};
|
||||
|
||||
// Create quality distribution chart
|
||||
const getQualityDistributionChartData = (): ChartSeries[] => {
|
||||
// Mock quality score distribution
|
||||
const distribution = [
|
||||
{ range: '9-10', count: Math.round(qualityData.totalChecks * 0.35), label: t('quality.excellent') },
|
||||
{ range: '8-9', count: Math.round(qualityData.totalChecks * 0.30), label: t('quality.good') },
|
||||
{ range: '7-8', count: Math.round(qualityData.totalChecks * 0.20), label: t('quality.acceptable') },
|
||||
{ range: '6-7', count: Math.round(qualityData.totalChecks * 0.10), label: t('quality.poor') },
|
||||
{ range: '<6', count: Math.round(qualityData.totalChecks * 0.05), label: t('quality.failed') }
|
||||
].filter(item => item.count > 0);
|
||||
|
||||
return [
|
||||
{
|
||||
id: 'quality-distribution',
|
||||
name: t('quality.score_distribution'),
|
||||
type: 'bar',
|
||||
color: '#d97706',
|
||||
data: distribution.map(item => ({
|
||||
x: item.range,
|
||||
y: item.count,
|
||||
label: item.label
|
||||
}))
|
||||
}
|
||||
];
|
||||
};
|
||||
|
||||
const getScoreColor = (score: number) => {
|
||||
if (score >= 9) return 'text-green-600';
|
||||
if (score >= 8) return 'text-blue-600';
|
||||
if (score >= 7) return 'text-orange-600';
|
||||
return 'text-red-600';
|
||||
};
|
||||
|
||||
const getScoreStatus = (score: number) => {
|
||||
if (score >= 9) return { status: 'excellent', bgColor: 'bg-green-100 dark:bg-green-900/20' };
|
||||
if (score >= 8) return { status: 'good', bgColor: 'bg-blue-100 dark:bg-blue-900/20' };
|
||||
if (score >= 7) return { status: 'acceptable', bgColor: 'bg-orange-100 dark:bg-orange-900/20' };
|
||||
return { status: 'needs_improvement', bgColor: 'bg-red-100 dark:bg-red-900/20' };
|
||||
};
|
||||
|
||||
const scoreStatus = getScoreStatus(qualityData.averageScore);
|
||||
const TrendIcon = qualityData.trend >= 0 ? TrendingUp : TrendingDown;
|
||||
|
||||
return (
|
||||
<AnalyticsWidget
|
||||
title={t('quality.recent_quality_scores')}
|
||||
subtitle={t('quality.daily_average_quality_trends')}
|
||||
icon={Star}
|
||||
loading={isLoading}
|
||||
error={error?.message}
|
||||
actions={
|
||||
<Button variant="outline" size="sm">
|
||||
<Award className="w-4 h-4 mr-1" />
|
||||
{t('quality.actions.view_trends')}
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
{/* Quality Score Display */}
|
||||
<div className="text-center">
|
||||
<div className="flex items-center justify-center space-x-2 mb-2">
|
||||
<Star className={`w-6 h-6 ${getScoreColor(qualityData.averageScore)}`} />
|
||||
<span className="text-3xl font-bold text-[var(--text-primary)]">
|
||||
{qualityData.averageScore.toFixed(1)}/10
|
||||
</span>
|
||||
</div>
|
||||
<div className={`inline-flex items-center space-x-2 px-3 py-1 rounded-full text-sm ${scoreStatus.bgColor}`}>
|
||||
<span className={getScoreColor(qualityData.averageScore)}>
|
||||
{t(`quality.${scoreStatus.status}`)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-center space-x-1 mt-2">
|
||||
<TrendIcon className={`w-4 h-4 ${qualityData.trend >= 0 ? 'text-green-600' : 'text-red-600'}`} />
|
||||
<span className={`text-sm ${qualityData.trend >= 0 ? 'text-green-600' : 'text-red-600'}`}>
|
||||
{qualityData.trend > 0 ? '+' : ''}{qualityData.trend.toFixed(1)} {t('quality.vs_last_week')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Key Metrics */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="p-4 bg-[var(--bg-secondary)] rounded-lg text-center">
|
||||
<div className="text-2xl font-bold text-[var(--text-primary)] mb-1">
|
||||
{qualityData.totalChecks}
|
||||
</div>
|
||||
<p className="text-sm text-[var(--text-secondary)]">{t('stats.total_checks')}</p>
|
||||
</div>
|
||||
<div className="p-4 bg-[var(--bg-secondary)] rounded-lg text-center">
|
||||
<div className="text-2xl font-bold text-green-600 mb-1">
|
||||
{qualityData.passRate.toFixed(1)}%
|
||||
</div>
|
||||
<p className="text-sm text-[var(--text-secondary)]">{t('stats.pass_rate')}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{qualityData.totalChecks === 0 ? (
|
||||
<div className="text-center py-8 text-[var(--text-secondary)]">
|
||||
<Star className="w-12 h-12 mx-auto mb-3 opacity-50" />
|
||||
<p className="text-sm">{t('quality.no_quality_data')}</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Weekly Quality Trend */}
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-[var(--text-primary)] mb-3">
|
||||
{t('quality.weekly_quality_trends')}
|
||||
</h4>
|
||||
<AnalyticsChart
|
||||
series={getQualityTrendChartData()}
|
||||
height={150}
|
||||
showLegend={false}
|
||||
showGrid={true}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Quality Score Distribution */}
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-[var(--text-primary)] mb-3">
|
||||
{t('quality.score_distribution')}
|
||||
</h4>
|
||||
<AnalyticsChart
|
||||
series={getQualityDistributionChartData()}
|
||||
height={180}
|
||||
showLegend={false}
|
||||
showGrid={true}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Quality Insights */}
|
||||
<div className="grid grid-cols-3 gap-3 text-center">
|
||||
<div className="p-3 bg-[var(--bg-secondary)] rounded-lg">
|
||||
<p className="text-lg font-bold text-green-600">
|
||||
{Math.round(qualityData.totalChecks * 0.35)}
|
||||
</p>
|
||||
<p className="text-xs text-[var(--text-secondary)]">{t('quality.excellent_scores')}</p>
|
||||
</div>
|
||||
<div className="p-3 bg-[var(--bg-secondary)] rounded-lg">
|
||||
<p className="text-lg font-bold text-blue-600">
|
||||
{Math.round(qualityData.totalChecks * 0.30)}
|
||||
</p>
|
||||
<p className="text-xs text-[var(--text-secondary)]">{t('quality.good_scores')}</p>
|
||||
</div>
|
||||
<div className="p-3 bg-[var(--bg-secondary)] rounded-lg">
|
||||
<p className="text-lg font-bold text-red-600">
|
||||
{Math.round(qualityData.totalChecks * 0.15)}
|
||||
</p>
|
||||
<p className="text-xs text-[var(--text-secondary)]">{t('quality.needs_improvement')}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Quality Recommendations */}
|
||||
{qualityData.averageScore < 8 && (
|
||||
<div className="p-3 bg-orange-50 dark:bg-orange-900/20 rounded-lg">
|
||||
<div className="flex items-start space-x-2">
|
||||
<Star className="w-4 h-4 mt-0.5 text-orange-600" />
|
||||
<div>
|
||||
<p className="text-sm font-medium text-orange-600">
|
||||
{t('quality.recommendations.improve_quality')}
|
||||
</p>
|
||||
<p className="text-xs text-[var(--text-secondary)] mt-1">
|
||||
{t('quality.recommendations.focus_consistency')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{qualityData.averageScore >= 9 && (
|
||||
<div className="p-3 bg-green-50 dark:bg-green-900/20 rounded-lg">
|
||||
<div className="flex items-start space-x-2">
|
||||
<Award className="w-4 h-4 mt-0.5 text-green-600" />
|
||||
<div>
|
||||
<p className="text-sm font-medium text-green-600">
|
||||
{t('quality.recommendations.excellent_quality')}
|
||||
</p>
|
||||
<p className="text-xs text-[var(--text-secondary)] mt-1">
|
||||
{t('quality.recommendations.maintain_standards')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</AnalyticsWidget>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,176 @@
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Calendar, Clock, Users, Target, AlertCircle } from 'lucide-react';
|
||||
import { AnalyticsWidget } from '../AnalyticsWidget';
|
||||
import { Badge, Button } from '../../../../ui';
|
||||
import { useProductionDashboard, useProductionSchedule } from '../../../../../api/hooks/production';
|
||||
import { useCurrentTenant } from '../../../../../stores/tenant.store';
|
||||
import { ProductionStatus } from '../../../../../api/types/production';
|
||||
|
||||
export const TodaysScheduleSummaryWidget: React.FC = () => {
|
||||
const { t } = useTranslation('production');
|
||||
const currentTenant = useCurrentTenant();
|
||||
const tenantId = currentTenant?.id || '';
|
||||
|
||||
const { data: dashboard, isLoading: dashboardLoading, error: dashboardError } = useProductionDashboard(tenantId);
|
||||
const today = new Date().toISOString().split('T')[0];
|
||||
const { data: schedule, isLoading: scheduleLoading, error: scheduleError } = useProductionSchedule(tenantId, today, today);
|
||||
|
||||
const isLoading = dashboardLoading || scheduleLoading;
|
||||
const error = dashboardError?.message || scheduleError?.message;
|
||||
|
||||
const todaysSchedule = schedule?.schedules?.[0];
|
||||
const plannedBatches = dashboard?.todays_production_plan || [];
|
||||
|
||||
const getStatusBadgeVariant = (status: ProductionStatus) => {
|
||||
switch (status) {
|
||||
case ProductionStatus.COMPLETED:
|
||||
return 'success';
|
||||
case ProductionStatus.IN_PROGRESS:
|
||||
return 'info';
|
||||
case ProductionStatus.PENDING:
|
||||
return 'warning';
|
||||
case ProductionStatus.CANCELLED:
|
||||
case ProductionStatus.FAILED:
|
||||
return 'error';
|
||||
default:
|
||||
return 'default';
|
||||
}
|
||||
};
|
||||
|
||||
const getPriorityColor = (priority?: string) => {
|
||||
if (!priority) return 'text-gray-600';
|
||||
|
||||
switch (priority) {
|
||||
case 'URGENT':
|
||||
return 'text-red-600';
|
||||
case 'HIGH':
|
||||
return 'text-orange-600';
|
||||
case 'MEDIUM':
|
||||
return 'text-blue-600';
|
||||
case 'LOW':
|
||||
return 'text-gray-600';
|
||||
default:
|
||||
return 'text-gray-600';
|
||||
}
|
||||
};
|
||||
|
||||
const formatTime = (timeString: string) => {
|
||||
return new Date(timeString).toLocaleTimeString('es-ES', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<AnalyticsWidget
|
||||
title={t('schedule.todays_summary')}
|
||||
subtitle={t('schedule.shift_hours_batches_staff')}
|
||||
icon={Calendar}
|
||||
loading={isLoading}
|
||||
error={error}
|
||||
actions={
|
||||
<Button variant="outline" size="sm">
|
||||
<Target className="w-4 h-4 mr-1" />
|
||||
{t('actions.optimize')}
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
{/* Schedule Overview */}
|
||||
{todaysSchedule && (
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 p-4 bg-[var(--bg-secondary)] rounded-lg">
|
||||
<div className="text-center">
|
||||
<Clock className="w-5 h-5 text-[var(--color-primary)] mx-auto mb-1" />
|
||||
<p className="text-sm text-[var(--text-secondary)]">{t('schedule.shift_hours')}</p>
|
||||
<p className="font-semibold text-[var(--text-primary)]">
|
||||
{formatTime(todaysSchedule.shift_start)} - {formatTime(todaysSchedule.shift_end)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<Target className="w-5 h-5 text-[var(--color-primary)] mx-auto mb-1" />
|
||||
<p className="text-sm text-[var(--text-secondary)]">{t('stats.planned_batches')}</p>
|
||||
<p className="font-semibold text-[var(--text-primary)]">{todaysSchedule.total_batches_planned}</p>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<Users className="w-5 h-5 text-[var(--color-primary)] mx-auto mb-1" />
|
||||
<p className="text-sm text-[var(--text-secondary)]">{t('schedule.staff_count')}</p>
|
||||
<p className="font-semibold text-[var(--text-primary)]">{todaysSchedule.staff_count}</p>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<AlertCircle className="w-5 h-5 text-[var(--color-primary)] mx-auto mb-1" />
|
||||
<p className="text-sm text-[var(--text-secondary)]">{t('schedule.capacity_utilization')}</p>
|
||||
<p className="font-semibold text-[var(--text-primary)]">
|
||||
{todaysSchedule.utilization_percentage?.toFixed(1) || 0}%
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Planned Batches */}
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-[var(--text-primary)] mb-3 flex items-center">
|
||||
<Target className="w-4 h-4 mr-2" />
|
||||
{t('schedule.planned_batches')} ({plannedBatches.length})
|
||||
</h4>
|
||||
|
||||
{plannedBatches.length === 0 ? (
|
||||
<div className="text-center py-6 text-[var(--text-secondary)]">
|
||||
<Calendar className="w-8 h-8 mx-auto mb-2 opacity-50" />
|
||||
<p className="text-sm">{t('messages.no_batches_planned')}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2 max-h-64 overflow-y-auto">
|
||||
{plannedBatches.map((batch, index) => (
|
||||
<div
|
||||
key={batch.batch_id || index}
|
||||
className="flex items-center justify-between p-3 bg-[var(--bg-secondary)] rounded-lg hover:bg-[var(--bg-tertiary)] transition-colors"
|
||||
>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center space-x-2 mb-1">
|
||||
<p className="font-medium text-[var(--text-primary)] truncate">
|
||||
{batch.product_name}
|
||||
</p>
|
||||
{batch.priority && (
|
||||
<span className={`text-xs font-medium ${getPriorityColor(batch.priority)}`}>
|
||||
{t(`priority.${batch.priority.toLowerCase()}`)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-sm text-[var(--text-secondary)]">
|
||||
{t('batch.planned_quantity')}: {batch.planned_quantity} {t('common.units')}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Badge variant={getStatusBadgeVariant(batch.status)}>
|
||||
{batch.status ? t(`status.${batch.status.toLowerCase()}`) : t('status.unknown')}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Schedule Status */}
|
||||
{todaysSchedule && (
|
||||
<div className="flex items-center justify-between pt-3 border-t border-[var(--border-primary)]">
|
||||
<div className="flex items-center space-x-2 text-sm">
|
||||
<div className={`w-2 h-2 rounded-full ${todaysSchedule.is_active ? 'bg-green-500' : 'bg-gray-400'}`} />
|
||||
<span className="text-[var(--text-secondary)]">
|
||||
{todaysSchedule.is_active ? t('schedule.active') : t('schedule.inactive')}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-sm text-[var(--text-secondary)]">
|
||||
{todaysSchedule.is_finalized ? (
|
||||
<span className="text-green-600">{t('schedule.finalized')}</span>
|
||||
) : (
|
||||
<span className="text-orange-600">{t('schedule.draft')}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</AnalyticsWidget>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,329 @@
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { AlertCircle, Eye, Camera, CheckSquare } from 'lucide-react';
|
||||
import { AnalyticsWidget } from '../AnalyticsWidget';
|
||||
import { AnalyticsChart, ChartSeries } from '../AnalyticsChart';
|
||||
import { Button, Badge } from '../../../../ui';
|
||||
import { useActiveBatches } from '../../../../../api/hooks/production';
|
||||
import { useCurrentTenant } from '../../../../../stores/tenant.store';
|
||||
|
||||
interface DefectType {
|
||||
type: string;
|
||||
count: number;
|
||||
percentage: number;
|
||||
severity: 'low' | 'medium' | 'high';
|
||||
trend: 'up' | 'down' | 'stable';
|
||||
estimatedCost: number;
|
||||
}
|
||||
|
||||
export const TopDefectTypesWidget: React.FC = () => {
|
||||
const { t } = useTranslation('production');
|
||||
const currentTenant = useCurrentTenant();
|
||||
const tenantId = currentTenant?.id || '';
|
||||
|
||||
const { data: batchesData, isLoading, error } = useActiveBatches(tenantId);
|
||||
const batches = batchesData?.batches || [];
|
||||
|
||||
// Calculate defect data from batches
|
||||
const getDefectData = (): DefectType[] => {
|
||||
const totalDefects = batches.reduce((sum, batch) => sum + (batch.defect_quantity || 0), 0);
|
||||
|
||||
if (totalDefects === 0) return [];
|
||||
|
||||
// Mock defect type distribution (in real implementation, this would come from quality check defect_types)
|
||||
const defectTypes = [
|
||||
{
|
||||
type: 'burnt',
|
||||
count: Math.round(totalDefects * 0.32),
|
||||
percentage: 32,
|
||||
severity: 'high' as const,
|
||||
trend: 'down' as const,
|
||||
estimatedCost: 45.60
|
||||
},
|
||||
{
|
||||
type: 'underproofed',
|
||||
count: Math.round(totalDefects * 0.24),
|
||||
percentage: 24,
|
||||
severity: 'medium' as const,
|
||||
trend: 'stable' as const,
|
||||
estimatedCost: 28.90
|
||||
},
|
||||
{
|
||||
type: 'misshapen',
|
||||
count: Math.round(totalDefects * 0.19),
|
||||
percentage: 19,
|
||||
severity: 'medium' as const,
|
||||
trend: 'up' as const,
|
||||
estimatedCost: 22.40
|
||||
},
|
||||
{
|
||||
type: 'color_issues',
|
||||
count: Math.round(totalDefects * 0.15),
|
||||
percentage: 15,
|
||||
severity: 'low' as const,
|
||||
trend: 'stable' as const,
|
||||
estimatedCost: 15.20
|
||||
},
|
||||
{
|
||||
type: 'texture_problems',
|
||||
count: Math.round(totalDefects * 0.10),
|
||||
percentage: 10,
|
||||
severity: 'medium' as const,
|
||||
trend: 'down' as const,
|
||||
estimatedCost: 12.80
|
||||
}
|
||||
].filter(defect => defect.count > 0);
|
||||
|
||||
return defectTypes;
|
||||
};
|
||||
|
||||
const defectData = getDefectData();
|
||||
const totalDefects = defectData.reduce((sum, defect) => sum + defect.count, 0);
|
||||
const totalDefectCost = defectData.reduce((sum, defect) => sum + defect.estimatedCost, 0);
|
||||
|
||||
// Create defect distribution pie chart
|
||||
const getDefectDistributionChartData = (): ChartSeries[] => {
|
||||
if (defectData.length === 0) return [];
|
||||
|
||||
const colors = ['#dc2626', '#ea580c', '#d97706', '#ca8a04', '#65a30d'];
|
||||
const pieData = defectData.map((defect, index) => ({
|
||||
x: t(`quality.defects.${defect.type}`),
|
||||
y: defect.count,
|
||||
label: t(`quality.defects.${defect.type}`),
|
||||
color: colors[index % colors.length]
|
||||
}));
|
||||
|
||||
return [
|
||||
{
|
||||
id: 'defect-distribution',
|
||||
name: t('quality.defect_distribution'),
|
||||
type: 'pie',
|
||||
color: '#dc2626',
|
||||
data: pieData
|
||||
}
|
||||
];
|
||||
};
|
||||
|
||||
// Create defect trend over time
|
||||
const getDefectTrendChartData = (): ChartSeries[] => {
|
||||
if (defectData.length === 0) return [];
|
||||
|
||||
const days = ['Lun', 'Mar', 'Mié', 'Jue', 'Vie', 'Sáb', 'Dom'];
|
||||
|
||||
return defectData.slice(0, 3).map((defect, seriesIndex) => {
|
||||
const colors = ['#dc2626', '#ea580c', '#d97706'];
|
||||
const baseValue = defect.count / 7; // Average per day
|
||||
|
||||
const trendData = days.map((day, index) => {
|
||||
let value = baseValue;
|
||||
// Apply trend
|
||||
if (defect.trend === 'up') value *= (1 + index * 0.05);
|
||||
else if (defect.trend === 'down') value *= (1 - index * 0.05);
|
||||
|
||||
return {
|
||||
x: day,
|
||||
y: Math.max(0, value + (Math.random() - 0.5) * baseValue * 0.3),
|
||||
label: day
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
id: `defect-trend-${defect.type}`,
|
||||
name: t(`quality.defects.${defect.type}`),
|
||||
type: 'line' as const,
|
||||
color: colors[seriesIndex],
|
||||
data: trendData
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
const getSeverityBadgeVariant = (severity: DefectType['severity']) => {
|
||||
switch (severity) {
|
||||
case 'high': return 'error';
|
||||
case 'medium': return 'warning';
|
||||
case 'low': return 'info';
|
||||
default: return 'default';
|
||||
}
|
||||
};
|
||||
|
||||
const getTrendIcon = (trend: DefectType['trend']) => {
|
||||
switch (trend) {
|
||||
case 'up': return '↗️';
|
||||
case 'down': return '↘️';
|
||||
case 'stable': return '➡️';
|
||||
default: return '➡️';
|
||||
}
|
||||
};
|
||||
|
||||
const getTrendColor = (trend: DefectType['trend']) => {
|
||||
switch (trend) {
|
||||
case 'up': return 'text-red-600';
|
||||
case 'down': return 'text-green-600';
|
||||
case 'stable': return 'text-blue-600';
|
||||
default: return 'text-gray-600';
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<AnalyticsWidget
|
||||
title={t('quality.top_defect_types_24h')}
|
||||
subtitle={t('quality.defect_analysis_cost_impact')}
|
||||
icon={AlertCircle}
|
||||
loading={isLoading}
|
||||
error={error?.message}
|
||||
actions={
|
||||
<Button variant="outline" size="sm">
|
||||
<Eye className="w-4 h-4 mr-1" />
|
||||
{t('quality.actions.view_details')}
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
{/* Overall Defect Metrics */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="p-4 bg-[var(--bg-secondary)] rounded-lg text-center">
|
||||
<div className="flex items-center justify-center space-x-2 mb-1">
|
||||
<AlertCircle className="w-5 h-5 text-red-600" />
|
||||
<span className="text-2xl font-bold text-[var(--text-primary)]">
|
||||
{totalDefects}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm text-[var(--text-secondary)]">{t('quality.total_defects')}</p>
|
||||
</div>
|
||||
<div className="p-4 bg-[var(--bg-secondary)] rounded-lg text-center">
|
||||
<div className="flex items-center justify-center space-x-2 mb-1">
|
||||
<span className="text-2xl font-bold text-red-600">
|
||||
€{totalDefectCost.toFixed(0)}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm text-[var(--text-secondary)]">{t('quality.estimated_cost')}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{defectData.length === 0 ? (
|
||||
<div className="text-center py-8 text-[var(--text-secondary)]">
|
||||
<CheckSquare className="w-12 h-12 mx-auto mb-3 opacity-50" />
|
||||
<h3 className="text-lg font-medium text-[var(--text-primary)] mb-2">
|
||||
{t('quality.no_defects_detected')}
|
||||
</h3>
|
||||
<p className="text-sm">{t('quality.excellent_quality_standards')}</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Top Defect Types List */}
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-[var(--text-primary)] mb-3">
|
||||
{t('quality.defect_breakdown')}
|
||||
</h4>
|
||||
<div className="space-y-3 max-h-64 overflow-y-auto">
|
||||
{defectData.map((defect, index) => (
|
||||
<div key={index} className="flex items-center justify-between p-3 bg-[var(--bg-secondary)] rounded-lg">
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="w-8 h-8 bg-red-100 dark:bg-red-900/20 rounded-lg flex items-center justify-center">
|
||||
<span className="text-sm font-bold text-red-600">#{index + 1}</span>
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium text-[var(--text-primary)]">
|
||||
{t(`quality.defects.${defect.type}`)}
|
||||
</p>
|
||||
<div className="flex items-center space-x-3 text-xs text-[var(--text-secondary)]">
|
||||
<span>{defect.count} {t('quality.incidents')}</span>
|
||||
<span>•</span>
|
||||
<span>€{defect.estimatedCost.toFixed(2)} {t('quality.cost')}</span>
|
||||
<span className={getTrendColor(defect.trend)}>
|
||||
{getTrendIcon(defect.trend)} {t(`quality.trend.${defect.trend}`)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<span className="text-lg font-bold text-[var(--text-primary)]">
|
||||
{defect.percentage}%
|
||||
</span>
|
||||
<Badge variant={getSeverityBadgeVariant(defect.severity)}>
|
||||
{t(`quality.severity.${defect.severity}`)}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Defect Distribution Chart */}
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-[var(--text-primary)] mb-3">
|
||||
{t('quality.defect_distribution')}
|
||||
</h4>
|
||||
<AnalyticsChart
|
||||
series={getDefectDistributionChartData()}
|
||||
height={200}
|
||||
showLegend={true}
|
||||
showGrid={false}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Defect Trends Over Time */}
|
||||
{defectData.length > 0 && (
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-[var(--text-primary)] mb-3">
|
||||
{t('quality.top_defects_weekly_trend')}
|
||||
</h4>
|
||||
<AnalyticsChart
|
||||
series={getDefectTrendChartData()}
|
||||
height={150}
|
||||
showLegend={true}
|
||||
showGrid={true}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Defect Prevention Insights */}
|
||||
<div className="space-y-2">
|
||||
{defectData.some(d => d.severity === 'high') && (
|
||||
<div className="flex items-start space-x-2 p-3 bg-red-50 dark:bg-red-900/20 rounded-lg">
|
||||
<AlertCircle className="w-4 h-4 mt-0.5 text-red-600" />
|
||||
<div>
|
||||
<p className="text-sm font-medium text-red-600">
|
||||
{t('quality.recommendations.critical_defects')}
|
||||
</p>
|
||||
<p className="text-xs text-[var(--text-secondary)] mt-1">
|
||||
{t('quality.recommendations.immediate_action_required')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-start space-x-2 p-3 bg-blue-50 dark:bg-blue-900/20 rounded-lg">
|
||||
<Camera className="w-4 h-4 mt-0.5 text-blue-600" />
|
||||
<div>
|
||||
<p className="text-sm font-medium text-blue-600">
|
||||
{t('quality.recommendations.documentation')}
|
||||
</p>
|
||||
<p className="text-xs text-[var(--text-secondary)] mt-1">
|
||||
{t('quality.recommendations.photo_documentation_helps')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Action Items */}
|
||||
<div className="pt-3 border-t border-[var(--border-primary)]">
|
||||
<h5 className="text-sm font-medium text-[var(--text-primary)] mb-2">
|
||||
{t('quality.recommended_actions')}
|
||||
</h5>
|
||||
<div className="space-y-1 text-xs text-[var(--text-secondary)]">
|
||||
{defectData[0] && (
|
||||
<p>• {t('quality.actions.focus_on')} {t(`quality.defects.${defectData[0].type}`).toLowerCase()}</p>
|
||||
)}
|
||||
{defectData.some(d => d.trend === 'up') && (
|
||||
<p>• {t('quality.actions.investigate_increasing_defects')}</p>
|
||||
)}
|
||||
<p>• {t('quality.actions.review_process_controls')}</p>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</AnalyticsWidget>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,323 @@
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { AlertTriangle, Trash2, Target, TrendingDown } from 'lucide-react';
|
||||
import { AnalyticsWidget } from '../AnalyticsWidget';
|
||||
import { AnalyticsChart, ChartSeries } from '../AnalyticsChart';
|
||||
import { Button, Badge } from '../../../../ui';
|
||||
import { useActiveBatches } from '../../../../../api/hooks/production';
|
||||
import { useCurrentTenant } from '../../../../../stores/tenant.store';
|
||||
|
||||
interface WasteSource {
|
||||
source: string;
|
||||
count: number;
|
||||
percentage: number;
|
||||
cost: number;
|
||||
severity: 'low' | 'medium' | 'high';
|
||||
}
|
||||
|
||||
export const WasteDefectTrackerWidget: React.FC = () => {
|
||||
const { t } = useTranslation('production');
|
||||
const currentTenant = useCurrentTenant();
|
||||
const tenantId = currentTenant?.id || '';
|
||||
|
||||
const { data: batchesData, isLoading, error } = useActiveBatches(tenantId);
|
||||
const batches = batchesData?.batches || [];
|
||||
|
||||
// Calculate waste and defect data from batches
|
||||
const getWasteData = () => {
|
||||
const totalUnits = batches.reduce((sum, batch) => sum + (batch.actual_quantity || batch.planned_quantity || 0), 0);
|
||||
const totalWaste = batches.reduce((sum, batch) => sum + (batch.waste_quantity || 0), 0);
|
||||
const totalDefects = batches.reduce((sum, batch) => sum + (batch.defect_quantity || 0), 0);
|
||||
|
||||
const wastePercentage = totalUnits > 0 ? (totalWaste / totalUnits) * 100 : 0;
|
||||
const defectPercentage = totalUnits > 0 ? (totalDefects / totalUnits) * 100 : 0;
|
||||
|
||||
return {
|
||||
totalUnits,
|
||||
totalWaste,
|
||||
totalDefects,
|
||||
wastePercentage,
|
||||
defectPercentage
|
||||
};
|
||||
};
|
||||
|
||||
const wasteData = getWasteData();
|
||||
|
||||
// Mock waste sources data (in real implementation, this would come from quality check defect types)
|
||||
const getWasteSources = (): WasteSource[] => {
|
||||
return [
|
||||
{
|
||||
source: t('quality.defects.burnt'),
|
||||
count: Math.round(wasteData.totalWaste * 0.35),
|
||||
percentage: 35,
|
||||
cost: 45.20,
|
||||
severity: 'high'
|
||||
},
|
||||
{
|
||||
source: t('quality.defects.misshapen'),
|
||||
count: Math.round(wasteData.totalWaste * 0.28),
|
||||
percentage: 28,
|
||||
cost: 32.15,
|
||||
severity: 'medium'
|
||||
},
|
||||
{
|
||||
source: t('quality.defects.underproofed'),
|
||||
count: Math.round(wasteData.totalWaste * 0.20),
|
||||
percentage: 20,
|
||||
cost: 28.90,
|
||||
severity: 'medium'
|
||||
},
|
||||
{
|
||||
source: t('quality.defects.temperature_issues'),
|
||||
count: Math.round(wasteData.totalWaste * 0.12),
|
||||
percentage: 12,
|
||||
cost: 18.70,
|
||||
severity: 'low'
|
||||
},
|
||||
{
|
||||
source: t('quality.defects.expired'),
|
||||
count: Math.round(wasteData.totalWaste * 0.05),
|
||||
percentage: 5,
|
||||
cost: 8.40,
|
||||
severity: 'low'
|
||||
}
|
||||
].filter(source => source.count > 0);
|
||||
};
|
||||
|
||||
const wasteSources = getWasteSources();
|
||||
const totalWasteCost = wasteSources.reduce((sum, source) => sum + source.cost, 0);
|
||||
|
||||
// Create pie chart for waste sources
|
||||
const getWasteSourcesChartData = (): ChartSeries[] => {
|
||||
if (wasteSources.length === 0) return [];
|
||||
|
||||
const colors = ['#dc2626', '#ea580c', '#d97706', '#ca8a04', '#65a30d'];
|
||||
const pieData = wasteSources.map((source, index) => ({
|
||||
x: source.source,
|
||||
y: source.count,
|
||||
label: source.source,
|
||||
color: colors[index % colors.length]
|
||||
}));
|
||||
|
||||
return [
|
||||
{
|
||||
id: 'waste-sources',
|
||||
name: t('quality.waste_sources'),
|
||||
type: 'pie',
|
||||
color: '#dc2626',
|
||||
data: pieData
|
||||
}
|
||||
];
|
||||
};
|
||||
|
||||
// Create trend chart for waste over time
|
||||
const getWasteTrendChartData = (): ChartSeries[] => {
|
||||
const days = ['Lun', 'Mar', 'Mié', 'Jue', 'Vie', 'Sáb', 'Dom'];
|
||||
const wasteData = days.map((day, index) => ({
|
||||
x: day,
|
||||
y: Math.max(1, 8 - index + Math.random() * 3), // Decreasing trend with some variation
|
||||
label: day
|
||||
}));
|
||||
|
||||
const defectData = days.map((day, index) => ({
|
||||
x: day,
|
||||
y: Math.max(0.5, 5 - index * 0.5 + Math.random() * 2),
|
||||
label: day
|
||||
}));
|
||||
|
||||
return [
|
||||
{
|
||||
id: 'waste-trend',
|
||||
name: t('quality.waste_percentage'),
|
||||
type: 'line',
|
||||
color: '#dc2626',
|
||||
data: wasteData
|
||||
},
|
||||
{
|
||||
id: 'defect-trend',
|
||||
name: t('quality.defect_percentage'),
|
||||
type: 'line',
|
||||
color: '#ea580c',
|
||||
data: defectData
|
||||
}
|
||||
];
|
||||
};
|
||||
|
||||
const getSeverityBadgeVariant = (severity: WasteSource['severity']) => {
|
||||
switch (severity) {
|
||||
case 'high': return 'error';
|
||||
case 'medium': return 'warning';
|
||||
case 'low': return 'info';
|
||||
default: return 'default';
|
||||
}
|
||||
};
|
||||
|
||||
const getWasteStatus = () => {
|
||||
const totalWastePercentage = wasteData.wastePercentage + wasteData.defectPercentage;
|
||||
if (totalWastePercentage <= 3) return { status: 'excellent', color: 'text-green-600', bgColor: 'bg-green-100 dark:bg-green-900/20' };
|
||||
if (totalWastePercentage <= 5) return { status: 'good', color: 'text-blue-600', bgColor: 'bg-blue-100 dark:bg-blue-900/20' };
|
||||
if (totalWastePercentage <= 8) return { status: 'warning', color: 'text-orange-600', bgColor: 'bg-orange-100 dark:bg-orange-900/20' };
|
||||
return { status: 'critical', color: 'text-red-600', bgColor: 'bg-red-100 dark:bg-red-900/20' };
|
||||
};
|
||||
|
||||
const wasteStatus = getWasteStatus();
|
||||
const totalWastePercentage = wasteData.wastePercentage + wasteData.defectPercentage;
|
||||
|
||||
return (
|
||||
<AnalyticsWidget
|
||||
title={t('quality.waste_defect_tracker')}
|
||||
subtitle={t('quality.waste_sources_trends_costs')}
|
||||
icon={AlertTriangle}
|
||||
loading={isLoading}
|
||||
error={error?.message}
|
||||
actions={
|
||||
<Button variant="outline" size="sm">
|
||||
<Target className="w-4 h-4 mr-1" />
|
||||
{t('quality.actions.reduce_waste')}
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
{/* Overall Waste Metrics */}
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div className="p-3 bg-[var(--bg-secondary)] rounded-lg text-center">
|
||||
<div className="flex items-center justify-center space-x-2 mb-1">
|
||||
<Trash2 className="w-4 h-4 text-red-600" />
|
||||
<span className="text-lg font-bold text-[var(--text-primary)]">
|
||||
{totalWastePercentage.toFixed(1)}%
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs text-[var(--text-secondary)]">{t('quality.total_waste')}</p>
|
||||
</div>
|
||||
<div className="p-3 bg-[var(--bg-secondary)] rounded-lg text-center">
|
||||
<div className="flex items-center justify-center space-x-2 mb-1">
|
||||
<AlertTriangle className="w-4 h-4 text-orange-600" />
|
||||
<span className="text-lg font-bold text-[var(--text-primary)]">
|
||||
{wasteData.totalDefects}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs text-[var(--text-secondary)]">{t('quality.total_defects')}</p>
|
||||
</div>
|
||||
<div className="p-3 bg-[var(--bg-secondary)] rounded-lg text-center">
|
||||
<div className="flex items-center justify-center space-x-2 mb-1">
|
||||
<TrendingDown className="w-4 h-4 text-green-600" />
|
||||
<span className="text-lg font-bold text-[var(--text-primary)]">
|
||||
€{totalWasteCost.toFixed(0)}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs text-[var(--text-secondary)]">{t('cost.waste_cost')}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Waste Status */}
|
||||
<div className={`p-3 rounded-lg ${wasteStatus.bgColor}`}>
|
||||
<div className="flex items-center space-x-2">
|
||||
<AlertTriangle className={`w-4 h-4 ${wasteStatus.color}`} />
|
||||
<span className={`text-sm font-medium ${wasteStatus.color}`}>
|
||||
{t(`quality.status.${wasteStatus.status}`)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{wasteSources.length === 0 ? (
|
||||
<div className="text-center py-8 text-[var(--text-secondary)]">
|
||||
<Target className="w-12 h-12 mx-auto mb-3 opacity-50" />
|
||||
<p className="text-sm">{t('quality.no_waste_data')}</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Waste Sources Breakdown */}
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-[var(--text-primary)] mb-3">
|
||||
{t('quality.top_waste_sources')}
|
||||
</h4>
|
||||
<div className="space-y-2">
|
||||
{wasteSources.slice(0, 5).map((source, index) => (
|
||||
<div key={index} className="flex items-center justify-between p-3 bg-[var(--bg-secondary)] rounded-lg">
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="w-3 h-3 bg-red-500 rounded-full" />
|
||||
<div>
|
||||
<p className="text-sm font-medium text-[var(--text-primary)]">
|
||||
{source.source}
|
||||
</p>
|
||||
<p className="text-xs text-[var(--text-secondary)]">
|
||||
{source.count} {t('common.units')} • €{source.cost.toFixed(2)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<span className="text-sm font-semibold text-[var(--text-primary)]">
|
||||
{source.percentage}%
|
||||
</span>
|
||||
<Badge variant={getSeverityBadgeVariant(source.severity)}>
|
||||
{t(`quality.severity.${source.severity}`)}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Waste Sources Pie Chart */}
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-[var(--text-primary)] mb-3">
|
||||
{t('quality.waste_distribution')}
|
||||
</h4>
|
||||
<AnalyticsChart
|
||||
series={getWasteSourcesChartData()}
|
||||
height={200}
|
||||
showLegend={true}
|
||||
showGrid={false}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Weekly Waste Trend */}
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-[var(--text-primary)] mb-3">
|
||||
{t('quality.weekly_waste_trend')}
|
||||
</h4>
|
||||
<AnalyticsChart
|
||||
series={getWasteTrendChartData()}
|
||||
height={150}
|
||||
showLegend={true}
|
||||
showGrid={true}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Reduction Recommendations */}
|
||||
<div className="space-y-2">
|
||||
{wasteSources.filter(s => s.severity === 'high').length > 0 && (
|
||||
<div className="flex items-start space-x-2 p-3 bg-red-50 dark:bg-red-900/20 rounded-lg">
|
||||
<AlertTriangle className="w-4 h-4 mt-0.5 text-red-600" />
|
||||
<div>
|
||||
<p className="text-sm font-medium text-red-600">
|
||||
{t('quality.recommendations.high_waste_detected')}
|
||||
</p>
|
||||
<p className="text-xs text-[var(--text-secondary)] mt-1">
|
||||
{t('quality.recommendations.check_temperature_timing')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-start space-x-2 p-3 bg-blue-50 dark:bg-blue-900/20 rounded-lg">
|
||||
<Target className="w-4 h-4 mt-0.5 text-blue-600" />
|
||||
<div>
|
||||
<p className="text-sm font-medium text-blue-600">
|
||||
{t('quality.recommendations.improvement_opportunity')}
|
||||
</p>
|
||||
<p className="text-xs text-[var(--text-secondary)] mt-1">
|
||||
{totalWastePercentage > 5
|
||||
? t('quality.recommendations.reduce_waste_target', { target: '3%' })
|
||||
: t('quality.recommendations.maintain_quality_standards')
|
||||
}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</AnalyticsWidget>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,321 @@
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Target, TrendingUp, Award, BarChart3 } from 'lucide-react';
|
||||
import { AnalyticsWidget } from '../AnalyticsWidget';
|
||||
import { AnalyticsChart, ChartSeries } from '../AnalyticsChart';
|
||||
import { Button, Badge } from '../../../../ui';
|
||||
import { useActiveBatches } from '../../../../../api/hooks/production';
|
||||
import { useCurrentTenant } from '../../../../../stores/tenant.store';
|
||||
|
||||
interface ProductYieldData {
|
||||
product: string;
|
||||
averageYield: number;
|
||||
bestYield: number;
|
||||
worstYield: number;
|
||||
batchCount: number;
|
||||
trend: 'up' | 'down' | 'stable';
|
||||
performance: 'excellent' | 'good' | 'average' | 'poor';
|
||||
}
|
||||
|
||||
export const YieldPerformanceWidget: React.FC = () => {
|
||||
const { t } = useTranslation('production');
|
||||
const currentTenant = useCurrentTenant();
|
||||
const tenantId = currentTenant?.id || '';
|
||||
|
||||
const { data: batchesData, isLoading, error } = useActiveBatches(tenantId);
|
||||
const batches = batchesData?.batches || [];
|
||||
|
||||
// Calculate yield data from batches
|
||||
const getProductYieldData = (): ProductYieldData[] => {
|
||||
const productMap = new Map<string, number[]>();
|
||||
|
||||
batches.forEach(batch => {
|
||||
if (batch.yield_percentage) {
|
||||
const product = batch.product_name;
|
||||
if (!productMap.has(product)) {
|
||||
productMap.set(product, []);
|
||||
}
|
||||
productMap.get(product)!.push(batch.yield_percentage);
|
||||
}
|
||||
});
|
||||
|
||||
return Array.from(productMap.entries()).map(([product, yields]) => {
|
||||
const averageYield = yields.reduce((sum, yieldValue) => sum + yieldValue, 0) / yields.length;
|
||||
const bestYield = Math.max(...yields);
|
||||
const worstYield = Math.min(...yields);
|
||||
|
||||
// Simulate trend calculation (in real implementation, this would compare with historical data)
|
||||
const trend: 'up' | 'down' | 'stable' =
|
||||
averageYield > 92 ? 'up' :
|
||||
averageYield < 88 ? 'down' : 'stable';
|
||||
|
||||
const performance: 'excellent' | 'good' | 'average' | 'poor' =
|
||||
averageYield >= 95 ? 'excellent' :
|
||||
averageYield >= 90 ? 'good' :
|
||||
averageYield >= 85 ? 'average' : 'poor';
|
||||
|
||||
return {
|
||||
product,
|
||||
averageYield,
|
||||
bestYield,
|
||||
worstYield,
|
||||
batchCount: yields.length,
|
||||
trend,
|
||||
performance
|
||||
};
|
||||
}).sort((a, b) => b.averageYield - a.averageYield);
|
||||
};
|
||||
|
||||
const productYieldData = getProductYieldData();
|
||||
const overallYield = productYieldData.length > 0
|
||||
? productYieldData.reduce((sum, item) => sum + item.averageYield, 0) / productYieldData.length
|
||||
: 0;
|
||||
|
||||
// Create yield comparison chart
|
||||
const getYieldComparisonChartData = (): ChartSeries[] => {
|
||||
if (productYieldData.length === 0) return [];
|
||||
|
||||
const averageData = productYieldData.map(item => ({
|
||||
x: item.product,
|
||||
y: item.averageYield,
|
||||
label: item.product
|
||||
}));
|
||||
|
||||
const bestData = productYieldData.map(item => ({
|
||||
x: item.product,
|
||||
y: item.bestYield,
|
||||
label: item.product
|
||||
}));
|
||||
|
||||
return [
|
||||
{
|
||||
id: 'average-yield',
|
||||
name: t('stats.average_yield'),
|
||||
type: 'bar',
|
||||
color: '#d97706',
|
||||
data: averageData
|
||||
},
|
||||
{
|
||||
id: 'best-yield',
|
||||
name: t('stats.best_yield'),
|
||||
type: 'bar',
|
||||
color: '#16a34a',
|
||||
data: bestData
|
||||
}
|
||||
];
|
||||
};
|
||||
|
||||
// Create yield trend over time
|
||||
const getYieldTrendChartData = (): ChartSeries[] => {
|
||||
const days = ['Lun', 'Mar', 'Mié', 'Jue', 'Vie', 'Sáb', 'Dom'];
|
||||
const trendData = days.map((day, index) => ({
|
||||
x: day,
|
||||
y: Math.max(85, 90 + index * 0.5 + Math.random() * 3), // Slightly improving trend
|
||||
label: day
|
||||
}));
|
||||
|
||||
return [
|
||||
{
|
||||
id: 'yield-trend',
|
||||
name: t('stats.yield_trend'),
|
||||
type: 'line',
|
||||
color: '#16a34a',
|
||||
data: trendData
|
||||
}
|
||||
];
|
||||
};
|
||||
|
||||
const getPerformanceBadgeVariant = (performance: ProductYieldData['performance']) => {
|
||||
switch (performance) {
|
||||
case 'excellent': return 'success';
|
||||
case 'good': return 'info';
|
||||
case 'average': return 'warning';
|
||||
case 'poor': return 'error';
|
||||
default: return 'default';
|
||||
}
|
||||
};
|
||||
|
||||
const getTrendIcon = (trend: ProductYieldData['trend']) => {
|
||||
switch (trend) {
|
||||
case 'up': return TrendingUp;
|
||||
case 'down': return TrendingUp; // We'll rotate it in CSS for down
|
||||
case 'stable': return BarChart3;
|
||||
default: return BarChart3;
|
||||
}
|
||||
};
|
||||
|
||||
const getTrendColor = (trend: ProductYieldData['trend']) => {
|
||||
switch (trend) {
|
||||
case 'up': return 'text-green-600';
|
||||
case 'down': return 'text-red-600';
|
||||
case 'stable': return 'text-blue-600';
|
||||
default: return 'text-gray-600';
|
||||
}
|
||||
};
|
||||
|
||||
const getYieldStatus = () => {
|
||||
if (overallYield >= 95) return { status: 'excellent', color: 'text-green-600', bgColor: 'bg-green-100 dark:bg-green-900/20' };
|
||||
if (overallYield >= 90) return { status: 'good', color: 'text-blue-600', bgColor: 'bg-blue-100 dark:bg-blue-900/20' };
|
||||
if (overallYield >= 85) return { status: 'average', color: 'text-orange-600', bgColor: 'bg-orange-100 dark:bg-orange-900/20' };
|
||||
return { status: 'poor', color: 'text-red-600', bgColor: 'bg-red-100 dark:bg-red-900/20' };
|
||||
};
|
||||
|
||||
const yieldStatus = getYieldStatus();
|
||||
|
||||
return (
|
||||
<AnalyticsWidget
|
||||
title={t('stats.yield_performance_leaderboard')}
|
||||
subtitle={t('stats.product_yield_rankings_trends')}
|
||||
icon={Award}
|
||||
loading={isLoading}
|
||||
error={error?.message}
|
||||
actions={
|
||||
<Button variant="outline" size="sm">
|
||||
<Target className="w-4 h-4 mr-1" />
|
||||
{t('actions.optimize_yields')}
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
{/* Overall Yield Status */}
|
||||
<div className="text-center">
|
||||
<div className="flex items-center justify-center space-x-2 mb-2">
|
||||
<Award className={`w-6 h-6 ${yieldStatus.color}`} />
|
||||
<span className="text-3xl font-bold text-[var(--text-primary)]">
|
||||
{overallYield.toFixed(1)}%
|
||||
</span>
|
||||
</div>
|
||||
<div className={`inline-flex items-center space-x-2 px-3 py-1 rounded-full text-sm ${yieldStatus.bgColor}`}>
|
||||
<span className={yieldStatus.color}>
|
||||
{t(`performance.${yieldStatus.status}`)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{productYieldData.length === 0 ? (
|
||||
<div className="text-center py-8 text-[var(--text-secondary)]">
|
||||
<Award className="w-12 h-12 mx-auto mb-3 opacity-50" />
|
||||
<p className="text-sm">{t('stats.no_yield_data')}</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Yield Leaderboard */}
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-[var(--text-primary)] mb-3 flex items-center">
|
||||
<Award className="w-4 h-4 mr-2" />
|
||||
{t('stats.product_leaderboard')}
|
||||
</h4>
|
||||
<div className="space-y-2 max-h-64 overflow-y-auto">
|
||||
{productYieldData.map((item, index) => {
|
||||
const TrendIcon = getTrendIcon(item.trend);
|
||||
const trendColor = getTrendColor(item.trend);
|
||||
|
||||
return (
|
||||
<div key={index} className="flex items-center justify-between p-3 bg-[var(--bg-secondary)] rounded-lg">
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="flex items-center justify-center w-6 h-6 bg-[var(--color-primary)]/10 rounded-full text-xs font-bold text-[var(--color-primary)]">
|
||||
{index + 1}
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium text-[var(--text-primary)]">
|
||||
{item.product}
|
||||
</p>
|
||||
<div className="flex items-center space-x-2 text-xs text-[var(--text-secondary)]">
|
||||
<span>{item.batchCount} {t('stats.batches')}</span>
|
||||
<span>•</span>
|
||||
<span>{item.bestYield.toFixed(1)}% {t('stats.best')}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className={`flex items-center space-x-1 ${trendColor}`}>
|
||||
<TrendIcon
|
||||
className={`w-3 h-3 ${item.trend === 'down' ? 'transform rotate-180' : ''}`}
|
||||
/>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<div className="text-lg font-bold text-[var(--text-primary)]">
|
||||
{item.averageYield.toFixed(1)}%
|
||||
</div>
|
||||
<Badge variant={getPerformanceBadgeVariant(item.performance)} className="text-xs">
|
||||
{t(`performance.${item.performance}`)}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Yield Comparison Chart */}
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-[var(--text-primary)] mb-3">
|
||||
{t('stats.yield_comparison')}
|
||||
</h4>
|
||||
<AnalyticsChart
|
||||
series={getYieldComparisonChartData()}
|
||||
height={200}
|
||||
showLegend={true}
|
||||
showGrid={true}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Weekly Yield Trend */}
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-[var(--text-primary)] mb-3">
|
||||
{t('stats.weekly_yield_trend')}
|
||||
</h4>
|
||||
<AnalyticsChart
|
||||
series={getYieldTrendChartData()}
|
||||
height={150}
|
||||
showLegend={false}
|
||||
showGrid={true}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Yield Insights */}
|
||||
<div className="grid grid-cols-3 gap-3 text-center">
|
||||
<div className="p-3 bg-[var(--bg-secondary)] rounded-lg">
|
||||
<p className="text-lg font-bold text-green-600">
|
||||
{productYieldData.filter(p => p.performance === 'excellent').length}
|
||||
</p>
|
||||
<p className="text-xs text-[var(--text-secondary)]">{t('performance.excellent')}</p>
|
||||
</div>
|
||||
<div className="p-3 bg-[var(--bg-secondary)] rounded-lg">
|
||||
<p className="text-lg font-bold text-blue-600">
|
||||
{productYieldData.filter(p => p.performance === 'good').length}
|
||||
</p>
|
||||
<p className="text-xs text-[var(--text-secondary)]">{t('performance.good')}</p>
|
||||
</div>
|
||||
<div className="p-3 bg-[var(--bg-secondary)] rounded-lg">
|
||||
<p className="text-lg font-bold text-orange-600">
|
||||
{productYieldData.filter(p => p.performance === 'average' || p.performance === 'poor').length}
|
||||
</p>
|
||||
<p className="text-xs text-[var(--text-secondary)]">{t('performance.needs_improvement')}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Improvement Recommendations */}
|
||||
<div className="p-3 bg-blue-50 dark:bg-blue-900/20 rounded-lg">
|
||||
<div className="flex items-start space-x-2">
|
||||
<Target className="w-4 h-4 mt-0.5 text-blue-600" />
|
||||
<div>
|
||||
<p className="text-sm font-medium text-blue-600">
|
||||
{t('recommendations.yield_improvement')}
|
||||
</p>
|
||||
<p className="text-xs text-[var(--text-secondary)] mt-1">
|
||||
{overallYield < 90
|
||||
? t('recommendations.focus_on_low_performers')
|
||||
: t('recommendations.maintain_high_standards')
|
||||
}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</AnalyticsWidget>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,38 @@
|
||||
// Bakery Operations Overview Widgets
|
||||
export { TodaysScheduleSummaryWidget } from './TodaysScheduleSummaryWidget';
|
||||
export { LiveBatchTrackerWidget } from './LiveBatchTrackerWidget';
|
||||
export { OnTimeCompletionWidget } from './OnTimeCompletionWidget';
|
||||
export { CapacityUtilizationWidget } from './CapacityUtilizationWidget';
|
||||
|
||||
// Cost & Efficiency Monitoring Widgets
|
||||
export { CostPerUnitWidget } from './CostPerUnitWidget';
|
||||
export { WasteDefectTrackerWidget } from './WasteDefectTrackerWidget';
|
||||
export { YieldPerformanceWidget } from './YieldPerformanceWidget';
|
||||
|
||||
// Quality Assurance Panel Widgets
|
||||
export { QualityScoreTrendsWidget } from './QualityScoreTrendsWidget';
|
||||
export { TopDefectTypesWidget } from './TopDefectTypesWidget';
|
||||
|
||||
// Equipment & Maintenance Widgets
|
||||
export { EquipmentStatusWidget } from './EquipmentStatusWidget';
|
||||
export { MaintenanceScheduleWidget } from './MaintenanceScheduleWidget';
|
||||
export { EquipmentEfficiencyWidget } from './EquipmentEfficiencyWidget';
|
||||
|
||||
// AI Insights & Predictive Analytics Widgets
|
||||
export { AIInsightsWidget } from './AIInsightsWidget';
|
||||
export { PredictiveMaintenanceWidget } from './PredictiveMaintenanceWidget';
|
||||
|
||||
import React from 'react';
|
||||
|
||||
// Widget Types
|
||||
export interface WidgetConfig {
|
||||
id: string;
|
||||
title: string;
|
||||
component: React.ComponentType;
|
||||
category: 'operations' | 'cost' | 'quality' | 'equipment' | 'ai';
|
||||
size: 'sm' | 'md' | 'lg' | 'xl';
|
||||
minSubscription?: 'basic' | 'professional' | 'enterprise';
|
||||
}
|
||||
|
||||
// Export widget configurations (temporarily commented out due to import issues)
|
||||
// export const WIDGET_CONFIGS: WidgetConfig[] = [];
|
||||
@@ -1,9 +1,10 @@
|
||||
import React, { useState, useMemo, useEffect } from 'react';
|
||||
import { ChefHat, Package, Clock, Euro, Star, Plus, Trash2, X, Timer, Thermometer, Settings, FileText } from 'lucide-react';
|
||||
import { StatusModal, StatusModalField, StatusModalSection } from '../../ui/StatusModal/StatusModal';
|
||||
import React, { useState, useMemo } from 'react';
|
||||
import { ChefHat, Package, Clock, Star, Plus, Trash2, Settings, FileText } from 'lucide-react';
|
||||
import { AddModal } from '../../ui/AddModal/AddModal';
|
||||
import { RecipeCreate, RecipeIngredientCreate, MeasurementUnit } from '../../../api/types/recipes';
|
||||
import { useIngredients } from '../../../api/hooks/inventory';
|
||||
import { useCurrentTenant } from '../../../stores/tenant.store';
|
||||
import { statusColors } from '../../../styles/colors';
|
||||
|
||||
interface CreateRecipeModalProps {
|
||||
isOpen: boolean;
|
||||
@@ -15,7 +16,7 @@ interface CreateRecipeModalProps {
|
||||
* CreateRecipeModal - Modal for creating a new recipe
|
||||
* Comprehensive form for adding new recipes
|
||||
*/
|
||||
// Custom Ingredients Component for StatusModal
|
||||
// Custom Ingredients Component for AddModal
|
||||
const IngredientsComponent: React.FC<{
|
||||
value: RecipeIngredientCreate[];
|
||||
onChange: (value: RecipeIngredientCreate[]) => void;
|
||||
@@ -84,7 +85,7 @@ const IngredientsComponent: React.FC<{
|
||||
className="w-full px-2 py-1.5 border border-[var(--border-secondary)] rounded focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)] bg-[var(--bg-primary)] text-[var(--text-primary)] text-sm"
|
||||
>
|
||||
<option value="">Seleccionar...</option>
|
||||
{availableIngredients.map(ing => (
|
||||
{(availableIngredients || []).map(ing => (
|
||||
<option key={ing.value} value={ing.value}>{ing.label}</option>
|
||||
))}
|
||||
</select>
|
||||
@@ -141,16 +142,6 @@ export const CreateRecipeModal: React.FC<CreateRecipeModalProps> = ({
|
||||
onClose,
|
||||
onCreateRecipe
|
||||
}) => {
|
||||
// Extended form data interface to include UI-specific fields
|
||||
interface ExtendedFormData extends Omit<RecipeCreate, 'allergen_info' | 'dietary_tags' | 'nutritional_info'> {
|
||||
allergen_info_text: string;
|
||||
dietary_tags_text: string;
|
||||
nutritional_info_text: string;
|
||||
allergen_info: Record<string, any> | null;
|
||||
dietary_tags: Record<string, any> | null;
|
||||
nutritional_info: Record<string, any> | null;
|
||||
}
|
||||
|
||||
const [ingredientsList, setIngredientsList] = useState<RecipeIngredientCreate[]>([{
|
||||
ingredient_id: '',
|
||||
quantity: 1,
|
||||
@@ -158,50 +149,7 @@ export const CreateRecipeModal: React.FC<CreateRecipeModalProps> = ({
|
||||
ingredient_order: 1,
|
||||
is_optional: false
|
||||
}]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const [formData, setFormData] = useState<ExtendedFormData>({
|
||||
name: '',
|
||||
recipe_code: '',
|
||||
version: '1.0',
|
||||
finished_product_id: '', // This should come from a product selector
|
||||
description: '',
|
||||
category: '',
|
||||
cuisine_type: '',
|
||||
difficulty_level: 1,
|
||||
yield_quantity: 1,
|
||||
yield_unit: MeasurementUnit.UNITS,
|
||||
prep_time_minutes: 0,
|
||||
cook_time_minutes: 0,
|
||||
total_time_minutes: 0,
|
||||
rest_time_minutes: 0,
|
||||
target_margin_percentage: 30,
|
||||
instructions: null,
|
||||
preparation_notes: '',
|
||||
storage_instructions: '',
|
||||
quality_standards: '',
|
||||
quality_check_configuration: null,
|
||||
quality_check_points: null,
|
||||
common_issues: null,
|
||||
serves_count: 1,
|
||||
is_seasonal: false,
|
||||
season_start_month: undefined,
|
||||
season_end_month: undefined,
|
||||
is_signature_item: false,
|
||||
batch_size_multiplier: 1.0,
|
||||
minimum_batch_size: undefined,
|
||||
maximum_batch_size: undefined,
|
||||
optimal_production_temperature: undefined,
|
||||
optimal_humidity: undefined,
|
||||
allergen_info_text: '',
|
||||
dietary_tags_text: '',
|
||||
nutritional_info_text: '',
|
||||
allergen_info: null,
|
||||
dietary_tags: null,
|
||||
nutritional_info: null,
|
||||
ingredients: []
|
||||
});
|
||||
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
// Get tenant and fetch inventory data
|
||||
const currentTenant = useCurrentTenant();
|
||||
@@ -215,7 +163,8 @@ export const CreateRecipeModal: React.FC<CreateRecipeModalProps> = ({
|
||||
|
||||
// Separate finished products and ingredients
|
||||
const finishedProducts = useMemo(() =>
|
||||
inventoryItems.filter(item => item.product_type === 'finished_product')
|
||||
(inventoryItems || [])
|
||||
.filter(item => item.product_type === 'finished_product')
|
||||
.map(product => ({
|
||||
value: product.id,
|
||||
label: `${product.name} (${product.category || 'Sin categoría'})`
|
||||
@@ -225,7 +174,8 @@ export const CreateRecipeModal: React.FC<CreateRecipeModalProps> = ({
|
||||
|
||||
// Available ingredients for recipe ingredients
|
||||
const availableIngredients = useMemo(() =>
|
||||
inventoryItems.filter(item => item.product_type !== 'finished_product')
|
||||
(inventoryItems || [])
|
||||
.filter(item => item.product_type !== 'finished_product')
|
||||
.map(ingredient => ({
|
||||
value: ingredient.id,
|
||||
label: `${ingredient.name} (${ingredient.category || 'Sin categoría'})`
|
||||
@@ -233,7 +183,6 @@ export const CreateRecipeModal: React.FC<CreateRecipeModalProps> = ({
|
||||
[inventoryItems]
|
||||
);
|
||||
|
||||
|
||||
// Category options
|
||||
const categoryOptions = [
|
||||
{ value: 'bread', label: 'Pan' },
|
||||
@@ -285,100 +234,92 @@ export const CreateRecipeModal: React.FC<CreateRecipeModalProps> = ({
|
||||
{ value: 12, label: 'Diciembre' }
|
||||
];
|
||||
|
||||
|
||||
// Auto-calculate total time when time values change
|
||||
useEffect(() => {
|
||||
const prepTime = formData.prep_time_minutes || 0;
|
||||
const cookTime = formData.cook_time_minutes || 0;
|
||||
const restTime = formData.rest_time_minutes || 0;
|
||||
const newTotal = prepTime + cookTime + restTime;
|
||||
|
||||
if (newTotal !== formData.total_time_minutes) {
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
total_time_minutes: newTotal
|
||||
}));
|
||||
}
|
||||
}, [formData.prep_time_minutes, formData.cook_time_minutes, formData.rest_time_minutes]);
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!formData.name.trim()) {
|
||||
alert('El nombre de la receta es obligatorio');
|
||||
return;
|
||||
const validateIngredients = (ingredients: RecipeIngredientCreate[]): string | null => {
|
||||
if (!ingredients || ingredients.length === 0) {
|
||||
return 'Debe agregar al menos un ingrediente';
|
||||
}
|
||||
|
||||
if (!formData.category?.trim()) {
|
||||
alert('Debe seleccionar una categoría');
|
||||
return;
|
||||
const emptyIngredients = ingredients.filter(ing => !ing.ingredient_id.trim());
|
||||
if (emptyIngredients.length > 0) {
|
||||
return 'Todos los ingredientes deben tener un ingrediente seleccionado';
|
||||
}
|
||||
|
||||
if (!formData.finished_product_id.trim()) {
|
||||
alert('Debe seleccionar un producto terminado');
|
||||
return;
|
||||
const invalidQuantities = ingredients.filter(ing => ing.quantity <= 0);
|
||||
if (invalidQuantities.length > 0) {
|
||||
return 'Todas las cantidades deben ser mayor que 0';
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const handleSave = async (formData: Record<string, any>) => {
|
||||
// Validate ingredients
|
||||
const ingredientError = validateIngredients();
|
||||
const ingredientError = validateIngredients(formData.ingredients || []);
|
||||
if (ingredientError) {
|
||||
alert(ingredientError);
|
||||
return;
|
||||
throw new Error(ingredientError);
|
||||
}
|
||||
|
||||
// Validate seasonal dates if seasonal is enabled
|
||||
if (formData.is_seasonal) {
|
||||
if (!formData.season_start_month || !formData.season_end_month) {
|
||||
alert('Para recetas estacionales, debe especificar los meses de inicio y fin');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Validate difficulty level
|
||||
if (!formData.difficulty_level || formData.difficulty_level < 1 || formData.difficulty_level > 5) {
|
||||
alert('Debe especificar un nivel de dificultad válido (1-5)');
|
||||
return;
|
||||
if (formData.is_seasonal && (!formData.season_start_month || !formData.season_end_month)) {
|
||||
throw new Error('Para recetas estacionales, debe especificar los meses de inicio y fin');
|
||||
}
|
||||
|
||||
// Validate batch sizes
|
||||
if (formData.minimum_batch_size && formData.maximum_batch_size) {
|
||||
if (formData.minimum_batch_size > formData.maximum_batch_size) {
|
||||
alert('El tamaño mínimo de lote no puede ser mayor que el máximo');
|
||||
return;
|
||||
}
|
||||
if (formData.minimum_batch_size && formData.maximum_batch_size && formData.minimum_batch_size > formData.maximum_batch_size) {
|
||||
throw new Error('El tamaño mínimo de lote no puede ser mayor que el máximo');
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
setIsLoading(true);
|
||||
|
||||
// Generate recipe code if not provided
|
||||
const recipeCode = formData.recipe_code ||
|
||||
formData.name.substring(0, 3).toUpperCase() +
|
||||
String(Date.now()).slice(-3);
|
||||
|
||||
// Calculate total time including rest time
|
||||
const totalTime = (formData.prep_time_minutes || 0) +
|
||||
(formData.cook_time_minutes || 0) +
|
||||
(formData.rest_time_minutes || 0);
|
||||
const totalTime = (Number(formData.prep_time_minutes) || 0) +
|
||||
(Number(formData.cook_time_minutes) || 0) +
|
||||
(Number(formData.rest_time_minutes) || 0);
|
||||
|
||||
const recipeData: RecipeCreate = {
|
||||
...formData,
|
||||
version: formData.version || '1.0',
|
||||
name: formData.name,
|
||||
recipe_code: recipeCode,
|
||||
version: formData.version || '1.0',
|
||||
finished_product_id: formData.finished_product_id,
|
||||
description: formData.description || '',
|
||||
category: formData.category,
|
||||
cuisine_type: formData.cuisine_type || '',
|
||||
difficulty_level: Number(formData.difficulty_level),
|
||||
yield_quantity: Number(formData.yield_quantity),
|
||||
yield_unit: formData.yield_unit as MeasurementUnit,
|
||||
prep_time_minutes: Number(formData.prep_time_minutes) || 0,
|
||||
cook_time_minutes: Number(formData.cook_time_minutes) || 0,
|
||||
total_time_minutes: totalTime,
|
||||
// Transform string fields to proper objects/dictionaries
|
||||
rest_time_minutes: Number(formData.rest_time_minutes) || 0,
|
||||
target_margin_percentage: Number(formData.target_margin_percentage) || 30,
|
||||
instructions: formData.preparation_notes ? { steps: formData.preparation_notes } : null,
|
||||
preparation_notes: formData.preparation_notes || '',
|
||||
storage_instructions: formData.storage_instructions || '',
|
||||
quality_standards: formData.quality_standards || '',
|
||||
quality_check_configuration: null,
|
||||
quality_check_points: null,
|
||||
common_issues: null,
|
||||
serves_count: Number(formData.serves_count) || 1,
|
||||
is_seasonal: formData.is_seasonal || false,
|
||||
season_start_month: formData.is_seasonal ? Number(formData.season_start_month) : undefined,
|
||||
season_end_month: formData.is_seasonal ? Number(formData.season_end_month) : undefined,
|
||||
is_signature_item: formData.is_signature_item || false,
|
||||
batch_size_multiplier: Number(formData.batch_size_multiplier) || 1.0,
|
||||
minimum_batch_size: formData.minimum_batch_size ? Number(formData.minimum_batch_size) : undefined,
|
||||
maximum_batch_size: formData.maximum_batch_size ? Number(formData.maximum_batch_size) : undefined,
|
||||
optimal_production_temperature: formData.optimal_production_temperature ? Number(formData.optimal_production_temperature) : undefined,
|
||||
optimal_humidity: formData.optimal_humidity ? Number(formData.optimal_humidity) : undefined,
|
||||
allergen_info: formData.allergen_info_text ? { allergens: formData.allergen_info_text.split(',').map((a: string) => a.trim()) } : null,
|
||||
dietary_tags: formData.dietary_tags_text ? { tags: formData.dietary_tags_text.split(',').map((t: string) => t.trim()) } : null,
|
||||
nutritional_info: formData.nutritional_info_text ? { info: formData.nutritional_info_text } : null,
|
||||
// Clean up undefined values for optional fields
|
||||
season_start_month: formData.is_seasonal ? formData.season_start_month : undefined,
|
||||
season_end_month: formData.is_seasonal ? formData.season_end_month : undefined,
|
||||
minimum_batch_size: formData.minimum_batch_size || undefined,
|
||||
maximum_batch_size: formData.maximum_batch_size || undefined,
|
||||
optimal_production_temperature: formData.optimal_production_temperature || undefined,
|
||||
optimal_humidity: formData.optimal_humidity || undefined,
|
||||
// Validate and use the ingredients list from state
|
||||
ingredients: ingredientsList.filter(ing => ing.ingredient_id.trim() !== '')
|
||||
.map((ing, index) => ({
|
||||
// Use the ingredients from form data
|
||||
ingredients: (formData.ingredients || []).filter((ing: RecipeIngredientCreate) => ing.ingredient_id.trim() !== '')
|
||||
.map((ing: RecipeIngredientCreate, index: number) => ({
|
||||
...ing,
|
||||
ingredient_order: index + 1
|
||||
}))
|
||||
@@ -388,9 +329,7 @@ export const CreateRecipeModal: React.FC<CreateRecipeModalProps> = ({
|
||||
await onCreateRecipe(recipeData);
|
||||
}
|
||||
|
||||
onClose();
|
||||
|
||||
// Reset form and ingredients list
|
||||
// Reset ingredients list (AddModal will handle form reset)
|
||||
setIngredientsList([{
|
||||
ingredient_id: '',
|
||||
quantity: 1,
|
||||
@@ -398,225 +337,87 @@ export const CreateRecipeModal: React.FC<CreateRecipeModalProps> = ({
|
||||
ingredient_order: 1,
|
||||
is_optional: false
|
||||
}]);
|
||||
setFormData({
|
||||
name: '',
|
||||
recipe_code: '',
|
||||
version: '1.0',
|
||||
finished_product_id: '',
|
||||
description: '',
|
||||
category: '',
|
||||
cuisine_type: '',
|
||||
difficulty_level: 1,
|
||||
yield_quantity: 1,
|
||||
yield_unit: MeasurementUnit.UNITS,
|
||||
prep_time_minutes: 0,
|
||||
cook_time_minutes: 0,
|
||||
total_time_minutes: 0,
|
||||
rest_time_minutes: 0,
|
||||
target_margin_percentage: 30,
|
||||
instructions: null,
|
||||
preparation_notes: '',
|
||||
storage_instructions: '',
|
||||
quality_standards: '',
|
||||
quality_check_configuration: null,
|
||||
quality_check_points: null,
|
||||
common_issues: null,
|
||||
serves_count: 1,
|
||||
is_seasonal: false,
|
||||
season_start_month: undefined,
|
||||
season_end_month: undefined,
|
||||
is_signature_item: false,
|
||||
batch_size_multiplier: 1.0,
|
||||
minimum_batch_size: undefined,
|
||||
maximum_batch_size: undefined,
|
||||
optimal_production_temperature: undefined,
|
||||
optimal_humidity: undefined,
|
||||
allergen_info_text: '',
|
||||
dietary_tags_text: '',
|
||||
nutritional_info_text: '',
|
||||
allergen_info: null,
|
||||
dietary_tags: null,
|
||||
nutritional_info: null,
|
||||
ingredients: []
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error creating recipe:', error);
|
||||
alert('Error al crear la receta. Por favor, inténtelo de nuevo.');
|
||||
throw error; // Let AddModal handle error display
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Helper functions for ingredients management
|
||||
const addIngredient = () => {
|
||||
const newIngredient: RecipeIngredientCreate = {
|
||||
ingredient_id: '',
|
||||
quantity: 1,
|
||||
unit: MeasurementUnit.GRAMS,
|
||||
ingredient_order: ingredientsList.length + 1,
|
||||
is_optional: false
|
||||
};
|
||||
setIngredientsList(prev => [...prev, newIngredient]);
|
||||
const statusConfig = {
|
||||
color: statusColors.inProgress.primary,
|
||||
text: 'Nueva Receta',
|
||||
icon: Plus,
|
||||
isCritical: false,
|
||||
isHighlight: true
|
||||
};
|
||||
|
||||
const removeIngredient = (index: number) => {
|
||||
if (ingredientsList.length > 1) {
|
||||
setIngredientsList(prev => prev.filter((_, i) => i !== index));
|
||||
}
|
||||
};
|
||||
|
||||
const updateIngredient = (index: number, field: keyof RecipeIngredientCreate, value: any) => {
|
||||
setIngredientsList(prev => prev.map((ingredient, i) =>
|
||||
i === index ? { ...ingredient, [field]: value } : ingredient
|
||||
));
|
||||
};
|
||||
|
||||
const validateIngredients = (): string | null => {
|
||||
if (ingredientsList.length === 0) {
|
||||
return 'Debe agregar al menos un ingrediente';
|
||||
}
|
||||
|
||||
const emptyIngredients = ingredientsList.filter(ing => !ing.ingredient_id.trim());
|
||||
if (emptyIngredients.length > 0) {
|
||||
return 'Todos los ingredientes deben tener un ingrediente seleccionado';
|
||||
}
|
||||
|
||||
const invalidQuantities = ingredientsList.filter(ing => ing.quantity <= 0);
|
||||
if (invalidQuantities.length > 0) {
|
||||
return 'Todas las cantidades deben ser mayor que 0';
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
// Removed the getModalSections function since we're now using a custom modal
|
||||
|
||||
// Field change handler for StatusModal
|
||||
const handleFieldChange = (sectionIndex: number, fieldIndex: number, value: string | number) => {
|
||||
const sections = getSections();
|
||||
const section = sections[sectionIndex];
|
||||
const field = section.fields[fieldIndex];
|
||||
|
||||
// Special handling for ingredients component
|
||||
if (field.type === 'component' && field.componentProps?.onChange) {
|
||||
// This is handled directly by the component
|
||||
return;
|
||||
}
|
||||
|
||||
// Update form data based on field mapping
|
||||
const fieldMapping: Record<string, string> = {
|
||||
'Nombre': 'name',
|
||||
'Código de receta': 'recipe_code',
|
||||
'Versión': 'version',
|
||||
'Descripción': 'description',
|
||||
'Categoría': 'category',
|
||||
'Producto terminado': 'finished_product_id',
|
||||
'Tipo de cocina': 'cuisine_type',
|
||||
'Nivel de dificultad': 'difficulty_level',
|
||||
'Cantidad de rendimiento': 'yield_quantity',
|
||||
'Unidad de rendimiento': 'yield_unit',
|
||||
'Tiempo de preparación (min)': 'prep_time_minutes',
|
||||
'Tiempo de cocción (min)': 'cook_time_minutes',
|
||||
'Tiempo de reposo (min)': 'rest_time_minutes',
|
||||
'Porciones': 'serves_count',
|
||||
'Margen objetivo (%)': 'target_margin_percentage',
|
||||
'Receta estacional': 'is_seasonal',
|
||||
'Mes de inicio': 'season_start_month',
|
||||
'Mes de fin': 'season_end_month',
|
||||
'Receta estrella': 'is_signature_item',
|
||||
'Multiplicador de lote': 'batch_size_multiplier',
|
||||
'Tamaño mínimo de lote': 'minimum_batch_size',
|
||||
'Tamaño máximo de lote': 'maximum_batch_size',
|
||||
'Temperatura óptima (°C)': 'optimal_production_temperature',
|
||||
'Humedad óptima (%)': 'optimal_humidity',
|
||||
'Notas de preparación': 'preparation_notes',
|
||||
'Instrucciones de almacenamiento': 'storage_instructions',
|
||||
'Estándares de calidad': 'quality_standards',
|
||||
'Información de alérgenos': 'allergen_info_text',
|
||||
'Etiquetas dietéticas': 'dietary_tags_text',
|
||||
'Información nutricional': 'nutritional_info_text',
|
||||
};
|
||||
|
||||
const fieldKey = fieldMapping[field.label];
|
||||
if (fieldKey) {
|
||||
if (fieldKey === 'is_seasonal' || fieldKey === 'is_signature_item') {
|
||||
setFormData(prev => ({ ...prev, [fieldKey]: value === 'Sí' }));
|
||||
} else if (typeof value === 'string' && ['difficulty_level', 'yield_quantity', 'prep_time_minutes', 'cook_time_minutes', 'rest_time_minutes', 'serves_count', 'target_margin_percentage', 'season_start_month', 'season_end_month', 'batch_size_multiplier', 'minimum_batch_size', 'maximum_batch_size', 'optimal_production_temperature', 'optimal_humidity'].includes(fieldKey)) {
|
||||
setFormData(prev => ({ ...prev, [fieldKey]: Number(value) || 0 }));
|
||||
} else {
|
||||
setFormData(prev => ({ ...prev, [fieldKey]: value }));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Create sections for StatusModal
|
||||
const getSections = (): StatusModalSection[] => [
|
||||
const sections = [
|
||||
{
|
||||
title: 'Información Básica',
|
||||
icon: ChefHat,
|
||||
fields: [
|
||||
{
|
||||
label: 'Nombre',
|
||||
value: formData.name,
|
||||
type: 'text',
|
||||
editable: true,
|
||||
name: 'name',
|
||||
type: 'text' as const,
|
||||
required: true,
|
||||
placeholder: 'Ej: Pan de molde integral',
|
||||
validation: (value) => !String(value).trim() ? 'El nombre es obligatorio' : null
|
||||
validation: (value: string | number) => {
|
||||
const str = String(value).trim();
|
||||
return str.length < 2 ? 'El nombre debe tener al menos 2 caracteres' : null;
|
||||
}
|
||||
},
|
||||
{
|
||||
label: 'Código de receta',
|
||||
value: formData.recipe_code,
|
||||
type: 'text',
|
||||
editable: true,
|
||||
name: 'recipe_code',
|
||||
type: 'text' as const,
|
||||
placeholder: 'Ej: PAN001 (opcional)'
|
||||
},
|
||||
{
|
||||
label: 'Versión',
|
||||
value: formData.version,
|
||||
type: 'text',
|
||||
editable: true,
|
||||
name: 'version',
|
||||
type: 'text' as const,
|
||||
defaultValue: '1.0',
|
||||
placeholder: '1.0'
|
||||
},
|
||||
{
|
||||
label: 'Descripción',
|
||||
value: formData.description,
|
||||
type: 'textarea',
|
||||
editable: true,
|
||||
name: 'description',
|
||||
type: 'textarea' as const,
|
||||
placeholder: 'Descripción de la receta...',
|
||||
span: 2
|
||||
},
|
||||
{
|
||||
label: 'Categoría',
|
||||
value: formData.category,
|
||||
type: 'select',
|
||||
editable: true,
|
||||
name: 'category',
|
||||
type: 'select' as const,
|
||||
required: true,
|
||||
options: categoryOptions,
|
||||
validation: (value) => !String(value).trim() ? 'La categoría es obligatoria' : null
|
||||
placeholder: 'Seleccionar categoría...'
|
||||
},
|
||||
{
|
||||
label: 'Producto terminado',
|
||||
value: formData.finished_product_id,
|
||||
type: 'select',
|
||||
editable: true,
|
||||
name: 'finished_product_id',
|
||||
type: 'select' as const,
|
||||
required: true,
|
||||
options: finishedProducts,
|
||||
validation: (value) => !String(value).trim() ? 'Debe seleccionar un producto terminado' : null
|
||||
placeholder: 'Seleccionar producto...'
|
||||
},
|
||||
{
|
||||
label: 'Tipo de cocina',
|
||||
value: formData.cuisine_type,
|
||||
type: 'select',
|
||||
editable: true,
|
||||
options: cuisineTypeOptions
|
||||
name: 'cuisine_type',
|
||||
type: 'select' as const,
|
||||
options: cuisineTypeOptions,
|
||||
placeholder: 'Seleccionar tipo...'
|
||||
},
|
||||
{
|
||||
label: 'Nivel de dificultad',
|
||||
value: formData.difficulty_level,
|
||||
type: 'select',
|
||||
editable: true,
|
||||
name: 'difficulty_level',
|
||||
type: 'select' as const,
|
||||
required: true,
|
||||
defaultValue: 1,
|
||||
options: [
|
||||
{ value: 1, label: '1 - Fácil' },
|
||||
{ value: 2, label: '2 - Medio' },
|
||||
@@ -627,122 +428,105 @@ export const CreateRecipeModal: React.FC<CreateRecipeModalProps> = ({
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
title: 'Ingredientes',
|
||||
icon: Package,
|
||||
fields: [
|
||||
{
|
||||
label: 'Lista de Ingredientes',
|
||||
value: ingredientsList,
|
||||
type: 'component',
|
||||
editable: true,
|
||||
component: IngredientsComponent,
|
||||
componentProps: {
|
||||
availableIngredients,
|
||||
unitOptions,
|
||||
onChange: setIngredientsList
|
||||
},
|
||||
span: 2,
|
||||
validation: () => validateIngredients()
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
title: 'Rendimiento y Tiempos',
|
||||
icon: Clock,
|
||||
fields: [
|
||||
{
|
||||
label: 'Cantidad de rendimiento',
|
||||
value: formData.yield_quantity,
|
||||
type: 'number',
|
||||
editable: true,
|
||||
name: 'yield_quantity',
|
||||
type: 'number' as const,
|
||||
required: true,
|
||||
validation: (value) => Number(value) <= 0 ? 'La cantidad debe ser mayor a 0' : null
|
||||
defaultValue: 1,
|
||||
validation: (value: string | number) => {
|
||||
const num = Number(value);
|
||||
return num <= 0 ? 'La cantidad debe ser mayor a 0' : null;
|
||||
}
|
||||
},
|
||||
{
|
||||
label: 'Unidad de rendimiento',
|
||||
value: formData.yield_unit,
|
||||
type: 'select',
|
||||
editable: true,
|
||||
name: 'yield_unit',
|
||||
type: 'select' as const,
|
||||
required: true,
|
||||
options: unitOptions
|
||||
options: unitOptions,
|
||||
defaultValue: MeasurementUnit.UNITS
|
||||
},
|
||||
{
|
||||
label: 'Porciones',
|
||||
value: formData.serves_count,
|
||||
type: 'number',
|
||||
editable: true,
|
||||
validation: (value) => Number(value) <= 0 ? 'Las porciones deben ser mayor a 0' : null
|
||||
name: 'serves_count',
|
||||
type: 'number' as const,
|
||||
defaultValue: 1,
|
||||
validation: (value: string | number) => {
|
||||
const num = Number(value);
|
||||
return num <= 0 ? 'Las porciones deben ser mayor a 0' : null;
|
||||
}
|
||||
},
|
||||
{
|
||||
label: 'Tiempo de preparación (min)',
|
||||
value: formData.prep_time_minutes || 0,
|
||||
type: 'number',
|
||||
editable: true,
|
||||
name: 'prep_time_minutes',
|
||||
type: 'number' as const,
|
||||
defaultValue: 0,
|
||||
helpText: 'Tiempo de preparación de ingredientes'
|
||||
},
|
||||
{
|
||||
label: 'Tiempo de cocción (min)',
|
||||
value: formData.cook_time_minutes || 0,
|
||||
type: 'number',
|
||||
editable: true,
|
||||
name: 'cook_time_minutes',
|
||||
type: 'number' as const,
|
||||
defaultValue: 0,
|
||||
helpText: 'Tiempo de horneado o cocción'
|
||||
},
|
||||
{
|
||||
label: 'Tiempo de reposo (min)',
|
||||
value: formData.rest_time_minutes || 0,
|
||||
type: 'number',
|
||||
editable: true,
|
||||
name: 'rest_time_minutes',
|
||||
type: 'number' as const,
|
||||
defaultValue: 0,
|
||||
helpText: 'Tiempo de fermentación o reposo'
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
title: 'Configuración Estacional y Especial',
|
||||
title: 'Configuración Especial',
|
||||
icon: Star,
|
||||
collapsible: true,
|
||||
fields: [
|
||||
{
|
||||
label: 'Receta estacional',
|
||||
value: formData.is_seasonal ? 'Sí' : 'No',
|
||||
type: 'select',
|
||||
editable: true,
|
||||
name: 'is_seasonal',
|
||||
type: 'select' as const,
|
||||
options: [
|
||||
{ value: 'No', label: 'No' },
|
||||
{ value: 'Sí', label: 'Sí' }
|
||||
]
|
||||
{ value: false, label: 'No' },
|
||||
{ value: true, label: 'Sí' }
|
||||
],
|
||||
defaultValue: false
|
||||
},
|
||||
{
|
||||
label: 'Mes de inicio',
|
||||
name: 'season_start_month',
|
||||
type: 'select' as const,
|
||||
options: monthOptions,
|
||||
placeholder: 'Seleccionar mes...'
|
||||
},
|
||||
{
|
||||
label: 'Mes de fin',
|
||||
name: 'season_end_month',
|
||||
type: 'select' as const,
|
||||
options: monthOptions,
|
||||
placeholder: 'Seleccionar mes...'
|
||||
},
|
||||
...(formData.is_seasonal ? [
|
||||
{
|
||||
label: 'Mes de inicio',
|
||||
value: formData.season_start_month || '',
|
||||
type: 'select' as const,
|
||||
editable: true,
|
||||
options: monthOptions
|
||||
},
|
||||
{
|
||||
label: 'Mes de fin',
|
||||
value: formData.season_end_month || '',
|
||||
type: 'select' as const,
|
||||
editable: true,
|
||||
options: monthOptions
|
||||
}
|
||||
] : []),
|
||||
{
|
||||
label: 'Receta estrella',
|
||||
value: formData.is_signature_item ? 'Sí' : 'No',
|
||||
type: 'select',
|
||||
editable: true,
|
||||
name: 'is_signature_item',
|
||||
type: 'select' as const,
|
||||
options: [
|
||||
{ value: 'No', label: 'No' },
|
||||
{ value: 'Sí', label: 'Sí' }
|
||||
]
|
||||
{ value: false, label: 'No' },
|
||||
{ value: true, label: 'Sí' }
|
||||
],
|
||||
defaultValue: false
|
||||
},
|
||||
{
|
||||
label: 'Margen objetivo (%)',
|
||||
value: formData.target_margin_percentage || 30,
|
||||
type: 'number',
|
||||
editable: true,
|
||||
name: 'target_margin_percentage',
|
||||
type: 'number' as const,
|
||||
defaultValue: 30,
|
||||
helpText: 'Margen de beneficio objetivo para esta receta'
|
||||
}
|
||||
]
|
||||
@@ -750,41 +534,36 @@ export const CreateRecipeModal: React.FC<CreateRecipeModalProps> = ({
|
||||
{
|
||||
title: 'Configuración de Producción',
|
||||
icon: Settings,
|
||||
collapsible: true,
|
||||
fields: [
|
||||
{
|
||||
label: 'Multiplicador de lote',
|
||||
value: formData.batch_size_multiplier,
|
||||
type: 'number',
|
||||
editable: true,
|
||||
name: 'batch_size_multiplier',
|
||||
type: 'number' as const,
|
||||
defaultValue: 1.0,
|
||||
helpText: 'Factor de escala para producción'
|
||||
},
|
||||
{
|
||||
label: 'Tamaño mínimo de lote',
|
||||
value: formData.minimum_batch_size || '',
|
||||
type: 'number',
|
||||
editable: true,
|
||||
name: 'minimum_batch_size',
|
||||
type: 'number' as const,
|
||||
helpText: 'Cantidad mínima recomendada'
|
||||
},
|
||||
{
|
||||
label: 'Tamaño máximo de lote',
|
||||
value: formData.maximum_batch_size || '',
|
||||
type: 'number',
|
||||
editable: true,
|
||||
name: 'maximum_batch_size',
|
||||
type: 'number' as const,
|
||||
helpText: 'Cantidad máxima recomendada'
|
||||
},
|
||||
{
|
||||
label: 'Temperatura óptima (°C)',
|
||||
value: formData.optimal_production_temperature || '',
|
||||
type: 'number',
|
||||
editable: true,
|
||||
name: 'optimal_production_temperature',
|
||||
type: 'number' as const,
|
||||
helpText: 'Temperatura ideal de producción'
|
||||
},
|
||||
{
|
||||
label: 'Humedad óptima (%)',
|
||||
value: formData.optimal_humidity || '',
|
||||
type: 'number',
|
||||
editable: true,
|
||||
name: 'optimal_humidity',
|
||||
type: 'number' as const,
|
||||
helpText: 'Humedad ideal de producción'
|
||||
}
|
||||
]
|
||||
@@ -792,64 +571,76 @@ export const CreateRecipeModal: React.FC<CreateRecipeModalProps> = ({
|
||||
{
|
||||
title: 'Instrucciones y Calidad',
|
||||
icon: FileText,
|
||||
collapsible: true,
|
||||
fields: [
|
||||
{
|
||||
label: 'Notas de preparación',
|
||||
value: formData.preparation_notes,
|
||||
type: 'textarea',
|
||||
editable: true,
|
||||
name: 'preparation_notes',
|
||||
type: 'textarea' as const,
|
||||
placeholder: 'Instrucciones detalladas de preparación...',
|
||||
span: 2,
|
||||
helpText: 'Pasos detallados para la preparación'
|
||||
},
|
||||
{
|
||||
label: 'Instrucciones de almacenamiento',
|
||||
value: formData.storage_instructions,
|
||||
type: 'textarea',
|
||||
editable: true,
|
||||
name: 'storage_instructions',
|
||||
type: 'textarea' as const,
|
||||
placeholder: 'Como conservar el producto terminado...',
|
||||
span: 2,
|
||||
helpText: 'Condiciones de almacenamiento del producto final'
|
||||
},
|
||||
{
|
||||
label: 'Estándares de calidad',
|
||||
value: formData.quality_standards,
|
||||
type: 'textarea',
|
||||
editable: true,
|
||||
name: 'quality_standards',
|
||||
type: 'textarea' as const,
|
||||
placeholder: 'Criterios de calidad que debe cumplir...',
|
||||
span: 2,
|
||||
helpText: 'Criterios que debe cumplir el producto final'
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
title: 'Ingredientes',
|
||||
icon: Package,
|
||||
columns: 1,
|
||||
fields: [
|
||||
{
|
||||
label: 'Lista de ingredientes',
|
||||
name: 'ingredients',
|
||||
type: 'component' as const,
|
||||
component: IngredientsComponent,
|
||||
componentProps: {
|
||||
availableIngredients,
|
||||
unitOptions
|
||||
},
|
||||
defaultValue: ingredientsList,
|
||||
span: 2,
|
||||
helpText: 'Agrega los ingredientes necesarios para esta receta'
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
title: 'Información Nutricional',
|
||||
icon: Package,
|
||||
collapsible: true,
|
||||
columns: 1,
|
||||
fields: [
|
||||
{
|
||||
label: 'Información de alérgenos',
|
||||
value: formData.allergen_info_text,
|
||||
type: 'text',
|
||||
editable: true,
|
||||
name: 'allergen_info_text',
|
||||
type: 'text' as const,
|
||||
placeholder: 'Ej: Gluten, Lácteos, Huevos',
|
||||
helpText: 'Separar con comas los alérgenos presentes'
|
||||
},
|
||||
{
|
||||
label: 'Etiquetas dietéticas',
|
||||
value: formData.dietary_tags_text,
|
||||
type: 'text',
|
||||
editable: true,
|
||||
name: 'dietary_tags_text',
|
||||
type: 'text' as const,
|
||||
placeholder: 'Ej: Vegano, Sin gluten, Orgánico',
|
||||
helpText: 'Separar con comas las etiquetas dietéticas'
|
||||
},
|
||||
{
|
||||
label: 'Información nutricional',
|
||||
value: formData.nutritional_info_text,
|
||||
type: 'textarea',
|
||||
editable: true,
|
||||
name: 'nutritional_info_text',
|
||||
type: 'textarea' as const,
|
||||
placeholder: 'Calorías, proteínas, carbohidratos, etc.',
|
||||
helpText: 'Información nutricional por porción'
|
||||
}
|
||||
@@ -858,26 +649,19 @@ export const CreateRecipeModal: React.FC<CreateRecipeModalProps> = ({
|
||||
];
|
||||
|
||||
return (
|
||||
<StatusModal
|
||||
<AddModal
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
mode="edit"
|
||||
title="Nueva Receta"
|
||||
subtitle="Crear una nueva receta para la panadería"
|
||||
statusIndicator={{
|
||||
color: '#10b981',
|
||||
text: 'Creando',
|
||||
icon: ChefHat
|
||||
}}
|
||||
sections={getSections()}
|
||||
onFieldChange={handleFieldChange}
|
||||
onSave={handleSubmit}
|
||||
statusIndicator={statusConfig}
|
||||
sections={sections}
|
||||
size="2xl"
|
||||
loading={isLoading}
|
||||
mobileOptimized={true}
|
||||
showDefaultActions={true}
|
||||
loading={loading}
|
||||
onSave={handleSave}
|
||||
initialData={{ ingredients: ingredientsList }}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default CreateRecipeModal;
|
||||
export default CreateRecipeModal;
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
Badge,
|
||||
Select
|
||||
} from '../../ui';
|
||||
import { LoadingSpinner } from '../../shared';
|
||||
import { LoadingSpinner } from '../../ui';
|
||||
import { useCurrentTenant } from '../../../stores/tenant.store';
|
||||
import { useQualityTemplatesForRecipe } from '../../../api/hooks/qualityTemplates';
|
||||
import {
|
||||
|
||||
@@ -7,7 +7,6 @@ import React, { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Select } from '../../ui/Select';
|
||||
import { Button } from '../../ui/Button/Button';
|
||||
import { useSupplierEnums } from '../../../utils/enumHelpers';
|
||||
import { SupplierType, SupplierStatus, PaymentTerms } from '../../../api/types/suppliers';
|
||||
|
||||
interface CreateSupplierFormProps {
|
||||
@@ -29,7 +28,6 @@ export const CreateSupplierForm: React.FC<CreateSupplierFormProps> = ({
|
||||
onCancel
|
||||
}) => {
|
||||
const { t } = useTranslation(['suppliers', 'common']);
|
||||
const supplierEnums = useSupplierEnums();
|
||||
|
||||
const [formData, setFormData] = useState<SupplierFormData>({
|
||||
name: '',
|
||||
@@ -73,24 +71,30 @@ export const CreateSupplierForm: React.FC<CreateSupplierFormProps> = ({
|
||||
{/* Supplier Type - Using enum helper */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
{supplierEnums.getFieldLabel('supplier_type')} *
|
||||
{t('suppliers:labels.supplier_type')} *
|
||||
</label>
|
||||
<Select
|
||||
options={supplierEnums.getSupplierTypeOptions()}
|
||||
options={Object.values(SupplierType).map(value => ({
|
||||
value,
|
||||
label: t(`suppliers:types.${value.toLowerCase()}`)
|
||||
}))}
|
||||
value={formData.supplier_type}
|
||||
onChange={(value) => handleFieldChange('supplier_type', value as SupplierType)}
|
||||
placeholder={t('common:forms.select_option')}
|
||||
helperText={supplierEnums.getFieldDescription('supplier_type')}
|
||||
helperText={t('suppliers:descriptions.supplier_type')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Supplier Status - Using enum helper */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
{supplierEnums.getFieldLabel('supplier_status')}
|
||||
{t('suppliers:labels.supplier_status')}
|
||||
</label>
|
||||
<Select
|
||||
options={supplierEnums.getSupplierStatusOptions()}
|
||||
options={Object.values(SupplierStatus).map(value => ({
|
||||
value,
|
||||
label: t(`suppliers:status.${value.toLowerCase()}`)
|
||||
}))}
|
||||
value={formData.status}
|
||||
onChange={(value) => handleFieldChange('status', value as SupplierStatus)}
|
||||
placeholder={t('common:forms.select_option')}
|
||||
@@ -100,14 +104,17 @@ export const CreateSupplierForm: React.FC<CreateSupplierFormProps> = ({
|
||||
{/* Payment Terms - Using enum helper */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
{supplierEnums.getFieldLabel('payment_terms')} *
|
||||
{t('suppliers:labels.payment_terms')} *
|
||||
</label>
|
||||
<Select
|
||||
options={supplierEnums.getPaymentTermsOptions()}
|
||||
options={Object.values(PaymentTerms).map(value => ({
|
||||
value,
|
||||
label: t(`suppliers:payment_terms.${value.toLowerCase()}`)
|
||||
}))}
|
||||
value={formData.payment_terms}
|
||||
onChange={(value) => handleFieldChange('payment_terms', value as PaymentTerms)}
|
||||
placeholder={t('common:forms.select_option')}
|
||||
helperText={supplierEnums.getFieldDescription('payment_terms')}
|
||||
helperText={t('suppliers:descriptions.payment_terms')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -162,13 +169,13 @@ export const CreateSupplierForm: React.FC<CreateSupplierFormProps> = ({
|
||||
<h3 className="text-sm font-medium text-gray-700 mb-2">Current Selections:</h3>
|
||||
<ul className="text-sm text-gray-600 space-y-1">
|
||||
<li>
|
||||
<strong>Tipo:</strong> {supplierEnums.getSupplierTypeLabel(formData.supplier_type)}
|
||||
<strong>Tipo:</strong> {t(`suppliers:types.${formData.supplier_type.toLowerCase()}`)}
|
||||
</li>
|
||||
<li>
|
||||
<strong>Estado:</strong> {supplierEnums.getSupplierStatusLabel(formData.status)}
|
||||
<strong>Estado:</strong> {t(`suppliers:status.${formData.status.toLowerCase()}`)}
|
||||
</li>
|
||||
<li>
|
||||
<strong>Términos de Pago:</strong> {supplierEnums.getPaymentTermsLabel(formData.payment_terms)}
|
||||
<strong>Términos de Pago:</strong> {t(`suppliers:payment_terms.${formData.payment_terms.toLowerCase()}`)}
|
||||
</li>
|
||||
<li>
|
||||
<strong>Debug - Payment Terms Raw:</strong> {formData.payment_terms}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Plus, Users, Shield, Eye } from 'lucide-react';
|
||||
import { StatusModal } from '../../ui/StatusModal/StatusModal';
|
||||
import { Plus, Users, Shield } from 'lucide-react';
|
||||
import { AddModal } from '../../ui/AddModal/AddModal';
|
||||
import { TENANT_ROLES } from '../../../types/roles';
|
||||
import { statusColors } from '../../../styles/colors';
|
||||
|
||||
@@ -21,41 +21,16 @@ export const AddTeamMemberModal: React.FC<AddTeamMemberModalProps> = ({
|
||||
onAddMember,
|
||||
availableUsers
|
||||
}) => {
|
||||
const [formData, setFormData] = useState({
|
||||
userId: '',
|
||||
userEmail: '', // Add email field for manual input
|
||||
role: TENANT_ROLES.MEMBER
|
||||
});
|
||||
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [mode, setMode] = useState<'view' | 'edit'>('edit');
|
||||
|
||||
const handleFieldChange = (sectionIndex: number, fieldIndex: number, value: string | number | boolean) => {
|
||||
// Map field positions to form data fields
|
||||
const fieldMappings = [
|
||||
// Basic Information section
|
||||
['userId', 'userEmail', 'role']
|
||||
];
|
||||
|
||||
const fieldName = fieldMappings[sectionIndex]?.[fieldIndex] as keyof typeof formData;
|
||||
if (fieldName) {
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
[fieldName]: value
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
const handleSave = async (formData: Record<string, any>) => {
|
||||
// Validation - need either userId OR userEmail
|
||||
if (!formData.userId && !formData.userEmail) {
|
||||
alert('Por favor selecciona un usuario o ingresa un email');
|
||||
return;
|
||||
throw new Error('Por favor selecciona un usuario o ingresa un email');
|
||||
}
|
||||
|
||||
if (!formData.role) {
|
||||
alert('Por favor selecciona un rol');
|
||||
return;
|
||||
throw new Error('Por favor selecciona un rol');
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
@@ -66,33 +41,14 @@ export const AddTeamMemberModal: React.FC<AddTeamMemberModalProps> = ({
|
||||
role: formData.role
|
||||
});
|
||||
}
|
||||
|
||||
// Reset form
|
||||
setFormData({
|
||||
userId: '',
|
||||
userEmail: '',
|
||||
role: TENANT_ROLES.MEMBER
|
||||
});
|
||||
|
||||
onClose();
|
||||
} catch (error) {
|
||||
console.error('Error adding team member:', error);
|
||||
alert('Error al agregar el miembro del equipo. Por favor, intenta de nuevo.');
|
||||
throw error; // Let AddModal handle error display
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
// Reset form to initial values
|
||||
setFormData({
|
||||
userId: '',
|
||||
userEmail: '',
|
||||
role: TENANT_ROLES.MEMBER
|
||||
});
|
||||
onClose();
|
||||
};
|
||||
|
||||
const statusConfig = {
|
||||
color: statusColors.inProgress.primary,
|
||||
text: 'Nuevo Miembro',
|
||||
@@ -114,74 +70,69 @@ export const AddTeamMemberModal: React.FC<AddTeamMemberModalProps> = ({
|
||||
}))
|
||||
: [];
|
||||
|
||||
const getRoleDescription = (role: string) => {
|
||||
switch (role) {
|
||||
case TENANT_ROLES.ADMIN:
|
||||
return 'Los administradores pueden gestionar miembros del equipo y configuraciones.';
|
||||
case TENANT_ROLES.MEMBER:
|
||||
return 'Los miembros tienen acceso completo para trabajar con datos y funcionalidades.';
|
||||
case TENANT_ROLES.VIEWER:
|
||||
return 'Los observadores solo pueden ver datos, sin realizar cambios.';
|
||||
default:
|
||||
return '';
|
||||
// Create fields array conditionally based on available users
|
||||
const userFields = [];
|
||||
|
||||
// Add user selection field if we have users available
|
||||
if (userOptions.length > 0) {
|
||||
userFields.push({
|
||||
label: 'Usuario',
|
||||
name: 'userId',
|
||||
type: 'select' as const,
|
||||
options: userOptions,
|
||||
placeholder: 'Seleccionar usuario...',
|
||||
helpText: 'Selecciona un usuario existente o ingresa un email manualmente abajo'
|
||||
});
|
||||
}
|
||||
|
||||
// Add email field (always present)
|
||||
userFields.push({
|
||||
label: userOptions.length > 0 ? 'O Email del Usuario' : 'Email del Usuario',
|
||||
name: 'userEmail',
|
||||
type: 'email' as const,
|
||||
placeholder: 'usuario@ejemplo.com',
|
||||
helpText: userOptions.length > 0
|
||||
? 'Alternativamente, ingresa el email de un usuario nuevo'
|
||||
: 'Ingresa el email del usuario que quieres agregar',
|
||||
validation: (value: string | number) => {
|
||||
const email = String(value);
|
||||
if (email && !email.includes('@')) {
|
||||
return 'Por favor ingresa un email válido';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
// Add role field
|
||||
userFields.push({
|
||||
label: 'Rol',
|
||||
name: 'role',
|
||||
type: 'select' as const,
|
||||
required: true,
|
||||
options: roleOptions,
|
||||
defaultValue: TENANT_ROLES.MEMBER,
|
||||
helpText: 'Selecciona el nivel de acceso para este usuario'
|
||||
});
|
||||
|
||||
const sections = [
|
||||
{
|
||||
title: 'Información del Miembro',
|
||||
icon: Users,
|
||||
fields: [
|
||||
...(userOptions.length > 0 ? [{
|
||||
label: 'Usuario',
|
||||
value: formData.userId,
|
||||
type: 'select' as const,
|
||||
editable: true,
|
||||
required: !formData.userEmail, // Only required if email not provided
|
||||
options: userOptions,
|
||||
placeholder: 'Seleccionar usuario...'
|
||||
}] : []),
|
||||
{
|
||||
label: userOptions.length > 0 ? 'O Email del Usuario' : 'Email del Usuario',
|
||||
value: formData.userEmail,
|
||||
type: 'email' as const,
|
||||
editable: true,
|
||||
required: !formData.userId, // Only required if user not selected
|
||||
placeholder: 'usuario@ejemplo.com'
|
||||
},
|
||||
{
|
||||
label: 'Rol',
|
||||
value: formData.role,
|
||||
type: 'select' as const,
|
||||
editable: true,
|
||||
required: true,
|
||||
options: roleOptions
|
||||
},
|
||||
{
|
||||
label: 'Descripción del Rol',
|
||||
value: getRoleDescription(formData.role),
|
||||
type: 'text' as const,
|
||||
editable: false
|
||||
}
|
||||
]
|
||||
fields: userFields
|
||||
}
|
||||
];
|
||||
|
||||
return (
|
||||
<StatusModal
|
||||
<AddModal
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
mode={mode}
|
||||
title="Agregar Miembro al Equipo"
|
||||
subtitle="Agregar un nuevo miembro al equipo de la panadería"
|
||||
statusIndicator={statusConfig}
|
||||
sections={sections}
|
||||
size="lg"
|
||||
loading={loading}
|
||||
showDefaultActions={true}
|
||||
onSave={handleSave}
|
||||
onCancel={handleCancel}
|
||||
onFieldChange={handleFieldChange}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user