Improve the production frontend
This commit is contained in:
@@ -0,0 +1,383 @@
|
||||
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 {
|
||||
ProductionBatchCreate,
|
||||
ProductionPriorityEnum
|
||||
} from '../../../api/types/production';
|
||||
import { useProductionEnums } from '../../../utils/enumHelpers';
|
||||
import { useRecipes } from '../../../api/hooks/recipes';
|
||||
import { useIngredients } from '../../../api/hooks/inventory';
|
||||
import { useCurrentTenant } from '../../../stores/tenant.store';
|
||||
|
||||
interface CreateProductionBatchModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onCreateBatch?: (batchData: ProductionBatchCreate) => Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* CreateProductionBatchModal - Modal for creating a new production batch
|
||||
* Comprehensive form for adding new production batches
|
||||
*/
|
||||
export const CreateProductionBatchModal: React.FC<CreateProductionBatchModalProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
onCreateBatch
|
||||
}) => {
|
||||
const productionEnums = useProductionEnums();
|
||||
const currentTenant = useCurrentTenant();
|
||||
const tenantId = currentTenant?.id || '';
|
||||
|
||||
// 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 =>
|
||||
ing.type === 'finished_product' ||
|
||||
ing.category === 'finished_products' ||
|
||||
ing.name.toLowerCase().includes('pan') ||
|
||||
ing.name.toLowerCase().includes('pastel') ||
|
||||
ing.name.toLowerCase().includes('torta')
|
||||
);
|
||||
|
||||
const productOptions = finishedProducts.map(product => ({
|
||||
value: product.id,
|
||||
label: product.name
|
||||
}));
|
||||
|
||||
const recipeOptions = 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;
|
||||
}
|
||||
|
||||
// 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;
|
||||
}
|
||||
|
||||
setIsSubmitting(true);
|
||||
|
||||
try {
|
||||
if (onCreateBatch) {
|
||||
await onCreateBatch(formData);
|
||||
}
|
||||
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.');
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Define sections for StatusModal
|
||||
const sections: StatusModalSection[] = [
|
||||
{
|
||||
title: 'Información del Producto',
|
||||
icon: Package,
|
||||
fields: [
|
||||
{
|
||||
label: 'Producto a Producir *',
|
||||
value: formData.product_id,
|
||||
type: 'select',
|
||||
editable: true,
|
||||
required: true,
|
||||
options: productOptions,
|
||||
span: 2
|
||||
},
|
||||
{
|
||||
label: 'Receta a Utilizar',
|
||||
value: formData.recipe_id || '',
|
||||
type: 'select',
|
||||
editable: true,
|
||||
options: recipeOptions,
|
||||
span: 2
|
||||
},
|
||||
{
|
||||
label: 'Número de Lote',
|
||||
value: formData.batch_number || '',
|
||||
type: 'text',
|
||||
editable: true,
|
||||
placeholder: 'Se generará automáticamente si se deja vacío'
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
title: 'Planificación de Producción',
|
||||
icon: Clock,
|
||||
fields: [
|
||||
{
|
||||
label: 'Inicio Planificado *',
|
||||
value: formData.planned_start_time,
|
||||
type: 'datetime-local',
|
||||
editable: true,
|
||||
required: true
|
||||
},
|
||||
{
|
||||
label: 'Fin Planificado *',
|
||||
value: formData.planned_end_time,
|
||||
type: 'datetime-local',
|
||||
editable: true,
|
||||
required: true
|
||||
},
|
||||
{
|
||||
label: 'Cantidad Planificada *',
|
||||
value: formData.planned_quantity,
|
||||
type: 'number',
|
||||
editable: true,
|
||||
required: true
|
||||
},
|
||||
{
|
||||
label: 'Duración (minutos) *',
|
||||
value: formData.planned_duration_minutes,
|
||||
type: 'number',
|
||||
editable: true,
|
||||
required: true
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
title: 'Configuración y Prioridad',
|
||||
icon: AlertCircle,
|
||||
fields: [
|
||||
{
|
||||
label: 'Prioridad *',
|
||||
value: formData.priority,
|
||||
type: 'select',
|
||||
editable: true,
|
||||
required: true,
|
||||
options: productionEnums.getProductionPriorityOptions()
|
||||
},
|
||||
{
|
||||
label: 'Orden Urgente',
|
||||
value: formData.is_rush_order ? 'Sí' : 'No',
|
||||
type: 'select',
|
||||
editable: true,
|
||||
options: [
|
||||
{ label: 'Sí', value: 'true' },
|
||||
{ label: 'No', value: 'false' }
|
||||
]
|
||||
},
|
||||
{
|
||||
label: 'Receta Especial',
|
||||
value: formData.is_special_recipe ? 'Sí' : 'No',
|
||||
type: 'select',
|
||||
editable: true,
|
||||
options: [
|
||||
{ label: 'Sí', value: 'true' },
|
||||
{ label: 'No', value: 'false' }
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
title: 'Recursos y Notas',
|
||||
icon: Users,
|
||||
fields: [
|
||||
{
|
||||
label: 'Personal Asignado',
|
||||
value: Array.isArray(formData.staff_assigned) ? formData.staff_assigned.join(', ') : '',
|
||||
type: 'text',
|
||||
editable: true,
|
||||
placeholder: 'Separar nombres con comas (opcional)',
|
||||
span: 2
|
||||
},
|
||||
{
|
||||
label: 'Notas de Producción',
|
||||
value: formData.production_notes || '',
|
||||
type: 'text',
|
||||
editable: true,
|
||||
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;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<StatusModal
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
mode="edit"
|
||||
title="Nueva Orden de Producción"
|
||||
subtitle="Crear un nuevo lote de producción"
|
||||
sections={sections}
|
||||
size="xl"
|
||||
loading={isSubmitting}
|
||||
onFieldChange={handleFieldChange}
|
||||
image={undefined}
|
||||
onSave={handleSubmit}
|
||||
onCancel={onClose}
|
||||
showDefaultActions={true}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default CreateProductionBatchModal;
|
||||
@@ -1,802 +0,0 @@
|
||||
import React, { useState, useCallback, useMemo } from 'react';
|
||||
import { Card, Button, Badge, Input, Modal, Table, Select } from '../../ui';
|
||||
import type { Recipe, RecipeIngredient, RecipeInstruction, NutritionalInfo } from '../../../types/production.types';
|
||||
|
||||
// Local enum definition to avoid import issues
|
||||
enum DifficultyLevel {
|
||||
BEGINNER = 'beginner',
|
||||
INTERMEDIATE = 'intermediate',
|
||||
ADVANCED = 'advanced',
|
||||
EXPERT = 'expert',
|
||||
}
|
||||
|
||||
interface RecipeDisplayProps {
|
||||
className?: string;
|
||||
recipe: Recipe;
|
||||
editable?: boolean;
|
||||
showNutrition?: boolean;
|
||||
showCosting?: boolean;
|
||||
onScaleChange?: (scaleFactor: number, scaledRecipe: Recipe) => void;
|
||||
onVersionUpdate?: (newVersion: Recipe) => void;
|
||||
onCostCalculation?: (totalCost: number, costPerUnit: number) => void;
|
||||
}
|
||||
|
||||
interface ScaledIngredient extends RecipeIngredient {
|
||||
scaledQuantity: number;
|
||||
scaledCost?: number;
|
||||
}
|
||||
|
||||
interface ScaledInstruction extends RecipeInstruction {
|
||||
scaledDuration?: number;
|
||||
}
|
||||
|
||||
interface ScaledRecipe extends Omit<Recipe, 'ingredients' | 'instructions'> {
|
||||
scaledYieldQuantity: number;
|
||||
scaledTotalTime: number;
|
||||
ingredients: ScaledIngredient[];
|
||||
instructions: ScaledInstruction[];
|
||||
scaleFactor: number;
|
||||
estimatedTotalCost?: number;
|
||||
costPerScaledUnit?: number;
|
||||
}
|
||||
|
||||
interface TimerState {
|
||||
instructionId: string;
|
||||
duration: number;
|
||||
remaining: number;
|
||||
isActive: boolean;
|
||||
isComplete: boolean;
|
||||
}
|
||||
|
||||
const DIFFICULTY_COLORS = {
|
||||
[DifficultyLevel.BEGINNER]: 'bg-[var(--color-success)]/10 text-[var(--color-success)]',
|
||||
[DifficultyLevel.INTERMEDIATE]: 'bg-yellow-100 text-yellow-800',
|
||||
[DifficultyLevel.ADVANCED]: 'bg-[var(--color-primary)]/10 text-[var(--color-primary)]',
|
||||
[DifficultyLevel.EXPERT]: 'bg-[var(--color-error)]/10 text-[var(--color-error)]',
|
||||
};
|
||||
|
||||
const DIFFICULTY_LABELS = {
|
||||
[DifficultyLevel.BEGINNER]: 'Principiante',
|
||||
[DifficultyLevel.INTERMEDIATE]: 'Intermedio',
|
||||
[DifficultyLevel.ADVANCED]: 'Avanzado',
|
||||
[DifficultyLevel.EXPERT]: 'Experto',
|
||||
};
|
||||
|
||||
const ALLERGEN_ICONS: Record<string, string> = {
|
||||
gluten: '🌾',
|
||||
milk: '🥛',
|
||||
eggs: '🥚',
|
||||
nuts: '🥜',
|
||||
soy: '🫘',
|
||||
sesame: '🌰',
|
||||
fish: '🐟',
|
||||
shellfish: '🦐',
|
||||
};
|
||||
|
||||
const EQUIPMENT_ICONS: Record<string, string> = {
|
||||
oven: '🔥',
|
||||
mixer: '🥄',
|
||||
scale: '⚖️',
|
||||
bowl: '🥣',
|
||||
whisk: '🔄',
|
||||
spatula: '🍴',
|
||||
thermometer: '🌡️',
|
||||
timer: '⏰',
|
||||
};
|
||||
|
||||
export const RecipeDisplay: React.FC<RecipeDisplayProps> = ({
|
||||
className = '',
|
||||
recipe,
|
||||
editable = false,
|
||||
showNutrition = true,
|
||||
showCosting = false,
|
||||
onScaleChange,
|
||||
onVersionUpdate,
|
||||
onCostCalculation,
|
||||
}) => {
|
||||
const [scaleFactor, setScaleFactor] = useState<number>(1);
|
||||
const [activeTimers, setActiveTimers] = useState<Record<string, TimerState>>({});
|
||||
const [isNutritionModalOpen, setIsNutritionModalOpen] = useState(false);
|
||||
const [isCostingModalOpen, setIsCostingModalOpen] = useState(false);
|
||||
const [isEquipmentModalOpen, setIsEquipmentModalOpen] = useState(false);
|
||||
const [selectedInstruction, setSelectedInstruction] = useState<RecipeInstruction | null>(null);
|
||||
const [showAllergensDetail, setShowAllergensDetail] = useState(false);
|
||||
|
||||
// Mock ingredient costs for demonstration
|
||||
const ingredientCosts: Record<string, number> = {
|
||||
flour: 1.2, // €/kg
|
||||
water: 0.001, // €/l
|
||||
salt: 0.8, // €/kg
|
||||
yeast: 8.5, // €/kg
|
||||
sugar: 1.5, // €/kg
|
||||
butter: 6.2, // €/kg
|
||||
milk: 1.3, // €/l
|
||||
eggs: 0.25, // €/unit
|
||||
};
|
||||
|
||||
const scaledRecipe = useMemo((): ScaledRecipe => {
|
||||
const scaledIngredients: ScaledIngredient[] = recipe.ingredients.map(ingredient => ({
|
||||
...ingredient,
|
||||
scaledQuantity: ingredient.quantity * scaleFactor,
|
||||
scaledCost: ingredientCosts[ingredient.ingredient_id]
|
||||
? ingredientCosts[ingredient.ingredient_id] * ingredient.quantity * scaleFactor
|
||||
: undefined,
|
||||
}));
|
||||
|
||||
const scaledInstructions: ScaledInstruction[] = recipe.instructions.map(instruction => ({
|
||||
...instruction,
|
||||
scaledDuration: instruction.duration_minutes ? Math.ceil(instruction.duration_minutes * scaleFactor) : undefined,
|
||||
}));
|
||||
|
||||
const estimatedTotalCost = scaledIngredients.reduce((total, ingredient) =>
|
||||
total + (ingredient.scaledCost || 0), 0
|
||||
);
|
||||
|
||||
const scaledYieldQuantity = recipe.yield_quantity * scaleFactor;
|
||||
const costPerScaledUnit = scaledYieldQuantity > 0 ? estimatedTotalCost / scaledYieldQuantity : 0;
|
||||
|
||||
return {
|
||||
...recipe,
|
||||
scaledYieldQuantity,
|
||||
scaledTotalTime: Math.ceil(recipe.total_time_minutes * scaleFactor),
|
||||
ingredients: scaledIngredients,
|
||||
instructions: scaledInstructions,
|
||||
scaleFactor,
|
||||
estimatedTotalCost,
|
||||
costPerScaledUnit,
|
||||
};
|
||||
}, [recipe, scaleFactor, ingredientCosts]);
|
||||
|
||||
const handleScaleChange = useCallback((newScaleFactor: number) => {
|
||||
setScaleFactor(newScaleFactor);
|
||||
if (onScaleChange) {
|
||||
onScaleChange(newScaleFactor, scaledRecipe as unknown as Recipe);
|
||||
}
|
||||
}, [scaledRecipe, onScaleChange]);
|
||||
|
||||
const startTimer = (instruction: RecipeInstruction) => {
|
||||
if (!instruction.duration_minutes) return;
|
||||
|
||||
const timerState: TimerState = {
|
||||
instructionId: instruction.step_number.toString(),
|
||||
duration: instruction.duration_minutes * 60, // Convert to seconds
|
||||
remaining: instruction.duration_minutes * 60,
|
||||
isActive: true,
|
||||
isComplete: false,
|
||||
};
|
||||
|
||||
setActiveTimers(prev => ({
|
||||
...prev,
|
||||
[instruction.step_number.toString()]: timerState,
|
||||
}));
|
||||
|
||||
// Start countdown
|
||||
const interval = setInterval(() => {
|
||||
setActiveTimers(prev => {
|
||||
const current = prev[instruction.step_number.toString()];
|
||||
if (!current || current.remaining <= 0) {
|
||||
clearInterval(interval);
|
||||
return {
|
||||
...prev,
|
||||
[instruction.step_number.toString()]: {
|
||||
...current,
|
||||
remaining: 0,
|
||||
isActive: false,
|
||||
isComplete: true,
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
...prev,
|
||||
[instruction.step_number.toString()]: {
|
||||
...current,
|
||||
remaining: current.remaining - 1,
|
||||
}
|
||||
};
|
||||
});
|
||||
}, 1000);
|
||||
};
|
||||
|
||||
const stopTimer = (instructionId: string) => {
|
||||
setActiveTimers(prev => ({
|
||||
...prev,
|
||||
[instructionId]: {
|
||||
...prev[instructionId],
|
||||
isActive: false,
|
||||
}
|
||||
}));
|
||||
};
|
||||
|
||||
const formatTime = (seconds: number): string => {
|
||||
const hours = Math.floor(seconds / 3600);
|
||||
const minutes = Math.floor((seconds % 3600) / 60);
|
||||
const secs = seconds % 60;
|
||||
|
||||
if (hours > 0) {
|
||||
return `${hours}:${String(minutes).padStart(2, '0')}:${String(secs).padStart(2, '0')}`;
|
||||
}
|
||||
return `${minutes}:${String(secs).padStart(2, '0')}`;
|
||||
};
|
||||
|
||||
const renderScalingControls = () => (
|
||||
<Card className="p-4">
|
||||
<h3 className="font-semibold text-lg mb-4">Escalado de receta</h3>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 items-end">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-[var(--text-secondary)] mb-2">
|
||||
Factor de escala
|
||||
</label>
|
||||
<Input
|
||||
type="number"
|
||||
min="0.1"
|
||||
max="10"
|
||||
step="0.1"
|
||||
value={scaleFactor}
|
||||
onChange={(e) => handleScaleChange(parseFloat(e.target.value) || 1)}
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-[var(--text-secondary)] mb-2">
|
||||
Cantidad original
|
||||
</label>
|
||||
<p className="text-lg font-semibold text-[var(--text-primary)]">
|
||||
{recipe.yield_quantity} {recipe.yield_unit}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-[var(--text-secondary)] mb-2">
|
||||
Cantidad escalada
|
||||
</label>
|
||||
<p className="text-lg font-semibold text-[var(--color-info)]">
|
||||
{scaledRecipe.scaledYieldQuantity} {recipe.yield_unit}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 mt-4">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleScaleChange(0.5)}
|
||||
className={scaleFactor === 0.5 ? 'bg-[var(--color-info)]/5 border-blue-300' : ''}
|
||||
>
|
||||
1/2
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleScaleChange(1)}
|
||||
className={scaleFactor === 1 ? 'bg-[var(--color-info)]/5 border-blue-300' : ''}
|
||||
>
|
||||
1x
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleScaleChange(2)}
|
||||
className={scaleFactor === 2 ? 'bg-[var(--color-info)]/5 border-blue-300' : ''}
|
||||
>
|
||||
2x
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleScaleChange(3)}
|
||||
className={scaleFactor === 3 ? 'bg-[var(--color-info)]/5 border-blue-300' : ''}
|
||||
>
|
||||
3x
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleScaleChange(5)}
|
||||
className={scaleFactor === 5 ? 'bg-[var(--color-info)]/5 border-blue-300' : ''}
|
||||
>
|
||||
5x
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{scaleFactor !== 1 && (
|
||||
<div className="mt-4 p-3 bg-[var(--color-info)]/5 border border-[var(--color-info)]/20 rounded-lg">
|
||||
<p className="text-sm text-[var(--color-info)]">
|
||||
<span className="font-medium">Tiempo total escalado:</span> {Math.ceil(scaledRecipe.scaledTotalTime / 60)}h {scaledRecipe.scaledTotalTime % 60}m
|
||||
</p>
|
||||
{showCosting && scaledRecipe.estimatedTotalCost && (
|
||||
<p className="text-sm text-[var(--color-info)] mt-1">
|
||||
<span className="font-medium">Costo estimado:</span> €{scaledRecipe.estimatedTotalCost.toFixed(2)}
|
||||
(€{scaledRecipe.costPerScaledUnit?.toFixed(3)}/{recipe.yield_unit})
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
|
||||
const renderRecipeHeader = () => (
|
||||
<Card className="p-6">
|
||||
<div className="flex flex-col lg:flex-row gap-6">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-start justify-between mb-4">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-[var(--text-primary)]">{recipe.name}</h1>
|
||||
<p className="text-[var(--text-secondary)] mt-1">{recipe.description}</p>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Badge className={DIFFICULTY_COLORS[recipe.difficulty_level]}>
|
||||
{DIFFICULTY_LABELS[recipe.difficulty_level]}
|
||||
</Badge>
|
||||
<Badge className="bg-[var(--color-info)]/10 text-[var(--color-info)]">
|
||||
v{recipe.version}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<div>
|
||||
<p className="text-sm text-[var(--text-secondary)]">Preparación</p>
|
||||
<p className="font-semibold">{recipe.prep_time_minutes} min</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-[var(--text-secondary)]">Cocción</p>
|
||||
<p className="font-semibold">{recipe.cook_time_minutes} min</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-[var(--text-secondary)]">Total</p>
|
||||
<p className="font-semibold">{Math.ceil(recipe.total_time_minutes / 60)}h {recipe.total_time_minutes % 60}m</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-[var(--text-secondary)]">Rendimiento</p>
|
||||
<p className="font-semibold">{scaledRecipe.scaledYieldQuantity} {recipe.yield_unit}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{recipe.allergen_warnings.length > 0 && (
|
||||
<div className="mt-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-sm font-medium text-[var(--text-secondary)]">Alérgenos:</p>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setShowAllergensDetail(!showAllergensDetail)}
|
||||
>
|
||||
{showAllergensDetail ? 'Ocultar' : 'Ver detalles'}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2 mt-2">
|
||||
{recipe.allergen_warnings.map((allergen) => (
|
||||
<Badge
|
||||
key={allergen}
|
||||
className="bg-[var(--color-error)]/10 text-[var(--color-error)] border border-red-200"
|
||||
>
|
||||
{ALLERGEN_ICONS[allergen]} {allergen}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{showAllergensDetail && (
|
||||
<div className="mt-3 p-3 bg-red-50 border border-red-200 rounded-lg">
|
||||
<p className="text-sm text-[var(--color-error)]">
|
||||
⚠️ Este producto contiene los siguientes alérgenos. Revisar cuidadosamente
|
||||
antes del consumo si existe alguna alergia o intolerancia alimentaria.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{recipe.storage_instructions && (
|
||||
<div className="mt-4 p-3 bg-[var(--bg-secondary)] border border-[var(--border-primary)] rounded-lg">
|
||||
<p className="text-sm text-[var(--text-secondary)]">
|
||||
<span className="font-medium">Conservación:</span> {recipe.storage_instructions}
|
||||
{recipe.shelf_life_hours && (
|
||||
<span className="ml-2">
|
||||
• Vida útil: {Math.ceil(recipe.shelf_life_hours / 24)} días
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2 lg:w-48">
|
||||
{showNutrition && (
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setIsNutritionModalOpen(true)}
|
||||
className="w-full"
|
||||
>
|
||||
📊 Información nutricional
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{showCosting && (
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setIsCostingModalOpen(true)}
|
||||
className="w-full"
|
||||
>
|
||||
💰 Análisis de costos
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setIsEquipmentModalOpen(true)}
|
||||
className="w-full"
|
||||
>
|
||||
🔧 Equipo necesario
|
||||
</Button>
|
||||
|
||||
{editable && onVersionUpdate && (
|
||||
<Button variant="primary" className="w-full">
|
||||
✏️ Editar receta
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
|
||||
const renderIngredients = () => (
|
||||
<Card className="p-6">
|
||||
<h2 className="text-xl font-semibold mb-4">Ingredientes</h2>
|
||||
|
||||
<div className="space-y-3">
|
||||
{scaledRecipe.ingredients.map((ingredient, index) => (
|
||||
<div
|
||||
key={`${ingredient.ingredient_id}-${index}`}
|
||||
className="flex items-center justify-between py-2 border-b border-[var(--border-primary)] last:border-b-0"
|
||||
>
|
||||
<div className="flex-1">
|
||||
<p className="font-medium text-[var(--text-primary)]">{ingredient.ingredient_name}</p>
|
||||
{ingredient.preparation_notes && (
|
||||
<p className="text-sm text-[var(--text-secondary)] italic">{ingredient.preparation_notes}</p>
|
||||
)}
|
||||
{ingredient.is_optional && (
|
||||
<Badge className="bg-[var(--bg-tertiary)] text-[var(--text-secondary)] text-xs mt-1">
|
||||
Opcional
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="text-right ml-4">
|
||||
<p className="font-semibold text-lg">
|
||||
{ingredient.scaledQuantity.toFixed(ingredient.scaledQuantity < 1 ? 2 : 0)} {ingredient.unit}
|
||||
</p>
|
||||
{scaleFactor !== 1 && (
|
||||
<p className="text-sm text-[var(--text-tertiary)]">
|
||||
(original: {ingredient.quantity} {ingredient.unit})
|
||||
</p>
|
||||
)}
|
||||
{showCosting && ingredient.scaledCost && (
|
||||
<p className="text-sm text-[var(--color-success)]">
|
||||
€{ingredient.scaledCost.toFixed(2)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{showCosting && scaledRecipe.estimatedTotalCost && (
|
||||
<div className="mt-4 pt-4 border-t border-[var(--border-primary)]">
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="font-semibold">Costo total estimado:</span>
|
||||
<span className="text-xl font-bold text-[var(--color-success)]">
|
||||
€{scaledRecipe.estimatedTotalCost.toFixed(2)}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm text-[var(--text-secondary)] text-right">
|
||||
€{scaledRecipe.costPerScaledUnit?.toFixed(3)} por {recipe.yield_unit}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
|
||||
const renderInstructions = () => (
|
||||
<Card className="p-6">
|
||||
<h2 className="text-xl font-semibold mb-4">Instrucciones</h2>
|
||||
|
||||
<div className="space-y-4">
|
||||
{scaledRecipe.instructions.map((instruction, index) => {
|
||||
const timer = activeTimers[instruction.step_number.toString()];
|
||||
|
||||
return (
|
||||
<div
|
||||
key={instruction.step_number}
|
||||
className={`p-4 border rounded-lg ${
|
||||
instruction.critical_control_point
|
||||
? 'border-orange-300 bg-orange-50'
|
||||
: 'border-[var(--border-primary)] bg-[var(--bg-secondary)]'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="flex-shrink-0">
|
||||
<div className="w-8 h-8 bg-blue-600 text-white rounded-full flex items-center justify-center font-semibold">
|
||||
{instruction.step_number}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
{instruction.duration_minutes && (
|
||||
<Badge className="bg-[var(--color-info)]/10 text-[var(--color-info)]">
|
||||
⏱️ {instruction.scaledDuration || instruction.duration_minutes} min
|
||||
</Badge>
|
||||
)}
|
||||
|
||||
{instruction.temperature && (
|
||||
<Badge className="bg-[var(--color-error)]/10 text-[var(--color-error)]">
|
||||
🌡️ {instruction.temperature}°C
|
||||
</Badge>
|
||||
)}
|
||||
|
||||
{instruction.critical_control_point && (
|
||||
<Badge className="bg-[var(--color-primary)]/10 text-[var(--color-primary)]">
|
||||
🚨 PCC
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{instruction.duration_minutes && (
|
||||
<div className="flex gap-2">
|
||||
{!timer?.isActive && !timer?.isComplete && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => startTimer(instruction)}
|
||||
>
|
||||
▶️ Iniciar timer
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{timer?.isActive && (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-lg font-mono font-bold text-[var(--color-info)]">
|
||||
{formatTime(timer.remaining)}
|
||||
</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => stopTimer(instruction.step_number.toString())}
|
||||
>
|
||||
⏸️
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{timer?.isComplete && (
|
||||
<Badge className="bg-[var(--color-success)]/10 text-[var(--color-success)] animate-pulse">
|
||||
✅ Completado
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<p className="text-[var(--text-primary)] leading-relaxed mb-2">
|
||||
{instruction.instruction}
|
||||
</p>
|
||||
|
||||
{instruction.equipment && instruction.equipment.length > 0 && (
|
||||
<div className="mb-2">
|
||||
<p className="text-sm text-[var(--text-secondary)] mb-1">Equipo necesario:</p>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{instruction.equipment.map((equipment, idx) => (
|
||||
<Badge key={idx} className="bg-[var(--bg-tertiary)] text-[var(--text-secondary)] text-xs">
|
||||
{EQUIPMENT_ICONS[equipment.toLowerCase()] || '🔧'} {equipment}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{instruction.tips && (
|
||||
<div className="mt-3 p-2 bg-yellow-50 border border-yellow-200 rounded">
|
||||
<p className="text-sm text-yellow-800">
|
||||
💡 <span className="font-medium">Tip:</span> {instruction.tips}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
|
||||
const renderNutritionModal = () => (
|
||||
<Modal
|
||||
isOpen={isNutritionModalOpen}
|
||||
onClose={() => setIsNutritionModalOpen(false)}
|
||||
title="Información Nutricional"
|
||||
>
|
||||
{recipe.nutritional_info ? (
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="bg-[var(--bg-secondary)] p-4 rounded-lg">
|
||||
<p className="text-sm text-[var(--text-secondary)]">Calorías por porción</p>
|
||||
<p className="text-2xl font-bold text-[var(--text-primary)]">
|
||||
{recipe.nutritional_info.calories_per_serving || 'N/A'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-[var(--bg-secondary)] p-4 rounded-lg">
|
||||
<p className="text-sm text-[var(--text-secondary)]">Tamaño de porción</p>
|
||||
<p className="text-lg font-semibold text-[var(--text-primary)]">
|
||||
{recipe.nutritional_info.serving_size || 'N/A'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 gap-4">
|
||||
<div>
|
||||
<p className="text-sm text-[var(--text-secondary)]">Proteínas</p>
|
||||
<p className="text-lg font-semibold">{recipe.nutritional_info.protein_g || 0}g</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-[var(--text-secondary)]">Carbohidratos</p>
|
||||
<p className="text-lg font-semibold">{recipe.nutritional_info.carbohydrates_g || 0}g</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-[var(--text-secondary)]">Grasas</p>
|
||||
<p className="text-lg font-semibold">{recipe.nutritional_info.fat_g || 0}g</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-[var(--text-secondary)]">Fibra</p>
|
||||
<p className="text-lg font-semibold">{recipe.nutritional_info.fiber_g || 0}g</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-[var(--text-secondary)]">Azúcares</p>
|
||||
<p className="text-lg font-semibold">{recipe.nutritional_info.sugar_g || 0}g</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-[var(--text-secondary)]">Sodio</p>
|
||||
<p className="text-lg font-semibold">{recipe.nutritional_info.sodium_mg || 0}mg</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-[var(--color-info)]/5 p-4 rounded-lg">
|
||||
<p className="text-sm text-[var(--color-info)]">
|
||||
<span className="font-medium">Porciones por lote escalado:</span> {
|
||||
recipe.nutritional_info.servings_per_batch
|
||||
? Math.ceil(recipe.nutritional_info.servings_per_batch * scaleFactor)
|
||||
: 'N/A'
|
||||
}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-[var(--text-tertiary)] text-center py-8">
|
||||
No hay información nutricional disponible para esta receta.
|
||||
</p>
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
|
||||
const renderCostingModal = () => (
|
||||
<Modal
|
||||
isOpen={isCostingModalOpen}
|
||||
onClose={() => setIsCostingModalOpen(false)}
|
||||
title="Análisis de Costos"
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<div className="bg-green-50 border border-green-200 p-4 rounded-lg">
|
||||
<div className="flex justify-between items-center mb-2">
|
||||
<span className="font-semibold text-green-900">Costo total</span>
|
||||
<span className="text-2xl font-bold text-[var(--color-success)]">
|
||||
€{scaledRecipe.estimatedTotalCost?.toFixed(2)}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm text-[var(--color-success)]">
|
||||
€{scaledRecipe.costPerScaledUnit?.toFixed(3)} por {recipe.yield_unit}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 className="font-semibold mb-3">Desglose por ingrediente</h4>
|
||||
<div className="space-y-2">
|
||||
{scaledRecipe.ingredients.map((ingredient, index) => (
|
||||
<div key={index} className="flex justify-between items-center py-2 border-b border-[var(--border-primary)]">
|
||||
<div>
|
||||
<p className="font-medium">{ingredient.ingredient_name}</p>
|
||||
<p className="text-sm text-[var(--text-secondary)]">
|
||||
{ingredient.scaledQuantity.toFixed(2)} {ingredient.unit}
|
||||
</p>
|
||||
</div>
|
||||
<p className="font-semibold text-[var(--color-success)]">
|
||||
€{ingredient.scaledCost?.toFixed(2) || '0.00'}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-[var(--color-info)]/5 p-4 rounded-lg">
|
||||
<h4 className="font-semibold text-blue-900 mb-2">Análisis de rentabilidad</h4>
|
||||
<div className="space-y-1 text-sm text-[var(--color-info)]">
|
||||
<p>• Costo de ingredientes: €{scaledRecipe.estimatedTotalCost?.toFixed(2)}</p>
|
||||
<p>• Margen sugerido (300%): €{((scaledRecipe.estimatedTotalCost || 0) * 3).toFixed(2)}</p>
|
||||
<p>• Precio de venta sugerido por {recipe.yield_unit}: €{((scaledRecipe.costPerScaledUnit || 0) * 4).toFixed(2)}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
|
||||
const renderEquipmentModal = () => (
|
||||
<Modal
|
||||
isOpen={isEquipmentModalOpen}
|
||||
onClose={() => setIsEquipmentModalOpen(false)}
|
||||
title="Equipo Necesario"
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h4 className="font-semibold mb-3">Equipo general de la receta</h4>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{recipe.equipment_needed.map((equipment, index) => (
|
||||
<div key={index} className="flex items-center gap-2 p-2 bg-[var(--bg-secondary)] rounded">
|
||||
<span className="text-xl">
|
||||
{EQUIPMENT_ICONS[equipment.toLowerCase()] || '🔧'}
|
||||
</span>
|
||||
<span className="text-sm">{equipment}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 className="font-semibold mb-3">Equipo por instrucción</h4>
|
||||
<div className="space-y-2">
|
||||
{recipe.instructions
|
||||
.filter(instruction => instruction.equipment && instruction.equipment.length > 0)
|
||||
.map((instruction) => (
|
||||
<div key={instruction.step_number} className="border border-[var(--border-primary)] p-3 rounded">
|
||||
<p className="font-medium text-sm mb-2">
|
||||
Paso {instruction.step_number}
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{instruction.equipment?.map((equipment, idx) => (
|
||||
<Badge key={idx} className="bg-[var(--color-info)]/10 text-[var(--color-info)] text-xs">
|
||||
{EQUIPMENT_ICONS[equipment.toLowerCase()] || '🔧'} {equipment}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={`space-y-6 ${className}`}>
|
||||
{renderRecipeHeader()}
|
||||
{renderScalingControls()}
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{renderIngredients()}
|
||||
<div className="space-y-6">
|
||||
{renderInstructions()}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{renderNutritionModal()}
|
||||
{renderCostingModal()}
|
||||
{renderEquipmentModal()}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default RecipeDisplay;
|
||||
@@ -2,11 +2,9 @@
|
||||
export { default as ProductionSchedule } from './ProductionSchedule';
|
||||
export { default as BatchTracker } from './BatchTracker';
|
||||
export { default as QualityControl } from './QualityControl';
|
||||
export { default as RecipeDisplay } from './RecipeDisplay';
|
||||
|
||||
export { CreateProductionBatchModal } from './CreateProductionBatchModal';
|
||||
|
||||
// Export component props types
|
||||
export type { ProductionScheduleProps } from './ProductionSchedule';
|
||||
export type { BatchTrackerProps } from './BatchTracker';
|
||||
export type { QualityControlProps } from './QualityControl';
|
||||
export type { RecipeDisplayProps } from './RecipeDisplay';
|
||||
export type { QualityControlProps } from './QualityControl';
|
||||
Reference in New Issue
Block a user