Implement Phase 2: Recipe & Supplier wizard modals (JTBD-driven UX)

Following Jobs-To-Be-Done analysis, break down complex forms into multi-step wizards to reduce cognitive load for non-technical bakery owners.

**Core Infrastructure:**
- Add reusable WizardModal component with progress tracking, validation, and navigation
  - Multi-step progress bar with clickable previous steps
  - Per-step validation with clear error messaging
  - Back/Next/Complete navigation with loading states
  - Optional step skipping support
  - Responsive modal design (sm/md/lg/xl/2xl sizes)

**Recipe Wizard (4 steps):**
- Step 1 (Product): Name, category, finished product, cuisine type, difficulty, description
- Step 2 (Ingredients): Dynamic ingredient list with add/remove, quantities, units, optional flags
- Step 3 (Production): Times (prep/cook/rest), yield, batch sizes, temperature, humidity, special flags
- Step 4 (Review): Instructions, storage, nutritional info, allergens, final summary

**Supplier Wizard (3 steps):**
- Step 1 (Basic): Name, type, status, contact person, email, phone, tax ID, registration
- Step 2 (Delivery): Payment terms, lead time, minimum order, delivery schedule, address
- Step 3 (Review): Certifications, sustainability practices, notes, summary

**Benefits:**
- Reduces form overwhelm from 8 sections to 4 sequential steps (recipes) and 3 steps (suppliers)
- Clear progress indication and next actions
- Validation feedback per step instead of at end
- Summary review before final submission
- Matches mental model of "configure then review" workflow

Files:
- WizardModal: Reusable wizard infrastructure
- RecipeWizard: 4-step recipe creation (Product → Ingredients → Production → Review)
- SupplierWizard: 3-step supplier creation (Basic → Delivery → Review)

Related to Phase 1 (ConfigurationProgressWidget) for post-onboarding guidance.
This commit is contained in:
Claude
2025-11-06 18:01:11 +00:00
parent 170caa9a0e
commit 877e0b6b47
13 changed files with 2280 additions and 0 deletions

View File

@@ -0,0 +1,212 @@
import React from 'react';
import { Package, Plus, Trash2 } from 'lucide-react';
import type { WizardStepProps } from '../../../ui/WizardModal/WizardModal';
import type { RecipeCreate, RecipeIngredientCreate, MeasurementUnit } from '../../../../api/types/recipes';
interface RecipeIngredientsStepProps extends WizardStepProps {
recipeData: Partial<RecipeCreate>;
onUpdate: (data: Partial<RecipeCreate>) => void;
availableIngredients: Array<{ value: string; label: string }>;
unitOptions: Array<{ value: MeasurementUnit; label: string }>;
}
export const RecipeIngredientsStep: React.FC<RecipeIngredientsStepProps> = ({
recipeData,
onUpdate,
onNext,
onBack,
availableIngredients,
unitOptions
}) => {
const ingredients = recipeData.ingredients || [];
const addIngredient = () => {
const newIngredient: RecipeIngredientCreate = {
ingredient_id: '',
quantity: 1,
unit: 'grams' as MeasurementUnit,
ingredient_order: ingredients.length + 1,
is_optional: false
};
onUpdate({ ...recipeData, ingredients: [...ingredients, newIngredient] });
};
const removeIngredient = (index: number) => {
if (ingredients.length > 1) {
const updated = ingredients.filter((_, i) => i !== index);
onUpdate({ ...recipeData, ingredients: updated });
}
};
const updateIngredient = (index: number, field: keyof RecipeIngredientCreate, value: any) => {
const updated = ingredients.map((ing, i) =>
i === index ? { ...ing, [field]: value } : ing
);
onUpdate({ ...recipeData, ingredients: updated });
};
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
onNext();
};
// Validation: at least one ingredient with valid data
const isValid =
ingredients.length > 0 &&
ingredients.some((ing) => ing.ingredient_id && ing.ingredient_id.trim() !== '' && ing.quantity > 0);
return (
<form onSubmit={handleSubmit} className="space-y-6">
{/* Header */}
<div className="flex items-start gap-4 pb-4 border-b border-[var(--border-secondary)]">
<div className="w-12 h-12 rounded-full bg-[var(--color-primary)]/10 flex items-center justify-center flex-shrink-0">
<Package className="w-6 h-6 text-[var(--color-primary)]" />
</div>
<div className="flex-1">
<h3 className="text-lg font-semibold text-[var(--text-primary)] mb-1">
Ingredientes
</h3>
<p className="text-sm text-[var(--text-secondary)]">
Agrega los ingredientes necesarios para esta receta
</p>
</div>
<button
type="button"
onClick={addIngredient}
className="px-4 py-2 bg-[var(--color-primary)] text-white rounded-lg hover:bg-[var(--color-primary-dark)] transition-colors flex items-center gap-2 text-sm font-medium"
>
<Plus className="w-4 h-4" />
Agregar
</button>
</div>
{/* Ingredients List */}
<div className="space-y-4 max-h-[450px] overflow-y-auto pr-2">
{ingredients.length === 0 ? (
<div className="text-center py-12 border-2 border-dashed border-[var(--border-secondary)] rounded-lg">
<Package className="w-12 h-12 text-[var(--text-tertiary)] mx-auto mb-3" />
<p className="text-sm text-[var(--text-secondary)] mb-4">
No hay ingredientes agregados
</p>
<button
type="button"
onClick={addIngredient}
className="px-4 py-2 bg-[var(--color-primary)] text-white rounded-lg hover:bg-[var(--color-primary-dark)] transition-colors inline-flex items-center gap-2"
>
<Plus className="w-4 h-4" />
Agregar Primer Ingrediente
</button>
</div>
) : (
ingredients.map((ingredient, index) => (
<div
key={index}
className="p-4 border border-[var(--border-secondary)] rounded-lg bg-[var(--bg-secondary)]/50 space-y-3"
>
{/* Header */}
<div className="flex items-center justify-between">
<span className="text-sm font-medium text-[var(--text-primary)]">
Ingrediente #{index + 1}
</span>
<button
type="button"
onClick={() => removeIngredient(index)}
disabled={ingredients.length <= 1}
className="p-1.5 text-red-500 hover:text-red-700 hover:bg-red-50 rounded disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
title={ingredients.length <= 1 ? 'Debe haber al menos un ingrediente' : 'Eliminar'}
>
<Trash2 className="w-4 h-4" />
</button>
</div>
{/* Fields */}
<div className="grid grid-cols-1 gap-3">
{/* Ingredient Selection */}
<div>
<label className="block text-xs font-medium text-[var(--text-secondary)] mb-1">
Ingrediente <span className="text-red-500">*</span>
</label>
<select
value={ingredient.ingredient_id}
onChange={(e) => updateIngredient(index, 'ingredient_id', e.target.value)}
className="w-full px-3 py-2 border border-[var(--border-secondary)] rounded-lg focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)] bg-[var(--bg-primary)] text-[var(--text-primary)] text-sm"
required
>
<option value="">Seleccionar...</option>
{availableIngredients.map((ing) => (
<option key={ing.value} value={ing.value}>
{ing.label}
</option>
))}
</select>
</div>
<div className="grid grid-cols-3 gap-3">
{/* Quantity */}
<div>
<label className="block text-xs font-medium text-[var(--text-secondary)] mb-1">
Cantidad <span className="text-red-500">*</span>
</label>
<input
type="number"
value={ingredient.quantity}
onChange={(e) => updateIngredient(index, 'quantity', parseFloat(e.target.value) || 0)}
className="w-full px-3 py-2 border border-[var(--border-secondary)] rounded-lg focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)] bg-[var(--bg-primary)] text-[var(--text-primary)] text-sm"
min="0"
step="0.1"
required
/>
</div>
{/* Unit */}
<div>
<label className="block text-xs font-medium text-[var(--text-secondary)] mb-1">
Unidad <span className="text-red-500">*</span>
</label>
<select
value={ingredient.unit}
onChange={(e) => updateIngredient(index, 'unit', e.target.value as MeasurementUnit)}
className="w-full px-3 py-2 border border-[var(--border-secondary)] rounded-lg focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)] bg-[var(--bg-primary)] text-[var(--text-primary)] text-sm"
required
>
{unitOptions.map((unit) => (
<option key={unit.value} value={unit.value}>
{unit.label}
</option>
))}
</select>
</div>
{/* Optional Checkbox */}
<div className="flex items-end">
<label className="flex items-center gap-2 text-sm text-[var(--text-primary)] cursor-pointer pb-2">
<input
type="checkbox"
checked={ingredient.is_optional}
onChange={(e) => updateIngredient(index, 'is_optional', e.target.checked)}
className="w-4 h-4 text-[var(--color-primary)] border-[var(--border-secondary)] rounded focus:ring-[var(--color-primary)]"
/>
Opcional
</label>
</div>
</div>
</div>
</div>
))
)}
</div>
{/* Validation Message */}
{!isValid && ingredients.length > 0 && (
<div className="p-3 bg-[var(--color-warning)]/10 border border-[var(--color-warning)]/30 rounded-lg">
<p className="text-sm text-[var(--text-primary)]">
<span className="font-medium"> Atención:</span> Asegúrate de seleccionar al menos un ingrediente con cantidad válida.
</p>
</div>
)}
{/* Hidden submit button for form handling */}
<button type="submit" className="hidden" disabled={!isValid} />
</form>
);
};

View File

@@ -0,0 +1,187 @@
import React from 'react';
import { ChefHat } from 'lucide-react';
import type { WizardStepProps } from '../../../ui/WizardModal/WizardModal';
import type { RecipeCreate } from '../../../../api/types/recipes';
interface RecipeProductStepProps extends WizardStepProps {
recipeData: Partial<RecipeCreate>;
onUpdate: (data: Partial<RecipeCreate>) => void;
finishedProducts: Array<{ value: string; label: string }>;
categoryOptions: Array<{ value: string; label: string }>;
cuisineTypeOptions: Array<{ value: string; label: string }>;
}
export const RecipeProductStep: React.FC<RecipeProductStepProps> = ({
recipeData,
onUpdate,
onNext,
finishedProducts,
categoryOptions,
cuisineTypeOptions
}) => {
const handleFieldChange = (field: keyof RecipeCreate, value: any) => {
onUpdate({ ...recipeData, [field]: value });
};
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
onNext();
};
const isValid =
recipeData.name &&
recipeData.name.trim().length >= 2 &&
recipeData.finished_product_id &&
recipeData.category;
return (
<form onSubmit={handleSubmit} className="space-y-6">
{/* Header */}
<div className="flex items-start gap-4 pb-4 border-b border-[var(--border-secondary)]">
<div className="w-12 h-12 rounded-full bg-[var(--color-primary)]/10 flex items-center justify-center flex-shrink-0">
<ChefHat className="w-6 h-6 text-[var(--color-primary)]" />
</div>
<div className="flex-1">
<h3 className="text-lg font-semibold text-[var(--text-primary)] mb-1">
Información del Producto
</h3>
<p className="text-sm text-[var(--text-secondary)]">
Configura la información básica de tu receta
</p>
</div>
</div>
{/* Form Fields */}
<div className="space-y-5">
{/* Recipe Name */}
<div>
<label className="block text-sm font-medium text-[var(--text-primary)] mb-1.5">
Nombre de la Receta <span className="text-red-500">*</span>
</label>
<input
type="text"
value={recipeData.name || ''}
onChange={(e) => handleFieldChange('name', e.target.value)}
placeholder="Ej: Pan de molde integral"
className="w-full px-4 py-2.5 border border-[var(--border-secondary)] rounded-lg focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)] bg-[var(--bg-primary)] text-[var(--text-primary)]"
required
/>
{recipeData.name && recipeData.name.trim().length < 2 && (
<p className="mt-1 text-xs text-red-500">
El nombre debe tener al menos 2 caracteres
</p>
)}
</div>
{/* Finished Product */}
<div>
<label className="block text-sm font-medium text-[var(--text-primary)] mb-1.5">
Producto Terminado <span className="text-red-500">*</span>
</label>
<select
value={recipeData.finished_product_id || ''}
onChange={(e) => handleFieldChange('finished_product_id', e.target.value)}
className="w-full px-4 py-2.5 border border-[var(--border-secondary)] rounded-lg focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)] bg-[var(--bg-primary)] text-[var(--text-primary)]"
required
>
<option value="">Seleccionar producto...</option>
{finishedProducts.map((product) => (
<option key={product.value} value={product.value}>
{product.label}
</option>
))}
</select>
<p className="mt-1 text-xs text-[var(--text-tertiary)]">
El producto final que se obtiene con esta receta
</p>
</div>
{/* Category */}
<div>
<label className="block text-sm font-medium text-[var(--text-primary)] mb-1.5">
Categoría <span className="text-red-500">*</span>
</label>
<select
value={recipeData.category || ''}
onChange={(e) => handleFieldChange('category', e.target.value)}
className="w-full px-4 py-2.5 border border-[var(--border-secondary)] rounded-lg focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)] bg-[var(--bg-primary)] text-[var(--text-primary)]"
required
>
<option value="">Seleccionar categoría...</option>
{categoryOptions.map((category) => (
<option key={category.value} value={category.value}>
{category.label}
</option>
))}
</select>
</div>
<div className="grid grid-cols-2 gap-4">
{/* Cuisine Type */}
<div>
<label className="block text-sm font-medium text-[var(--text-primary)] mb-1.5">
Tipo de Cocina
</label>
<select
value={recipeData.cuisine_type || ''}
onChange={(e) => handleFieldChange('cuisine_type', e.target.value)}
className="w-full px-4 py-2.5 border border-[var(--border-secondary)] rounded-lg focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)] bg-[var(--bg-primary)] text-[var(--text-primary)]"
>
<option value="">Seleccionar tipo...</option>
{cuisineTypeOptions.map((type) => (
<option key={type.value} value={type.value}>
{type.label}
</option>
))}
</select>
</div>
{/* Difficulty Level */}
<div>
<label className="block text-sm font-medium text-[var(--text-primary)] mb-1.5">
Nivel de Dificultad <span className="text-red-500">*</span>
</label>
<select
value={recipeData.difficulty_level || 1}
onChange={(e) => handleFieldChange('difficulty_level', Number(e.target.value))}
className="w-full px-4 py-2.5 border border-[var(--border-secondary)] rounded-lg focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)] bg-[var(--bg-primary)] text-[var(--text-primary)]"
required
>
<option value={1}>1 - Fácil</option>
<option value={2}>2 - Medio</option>
<option value={3}>3 - Difícil</option>
<option value={4}>4 - Muy Difícil</option>
<option value={5}>5 - Extremo</option>
</select>
</div>
</div>
{/* Description */}
<div>
<label className="block text-sm font-medium text-[var(--text-primary)] mb-1.5">
Descripción (Opcional)
</label>
<textarea
value={recipeData.description || ''}
onChange={(e) => handleFieldChange('description', e.target.value)}
placeholder="Descripción breve de la receta..."
rows={3}
className="w-full px-4 py-2.5 border border-[var(--border-secondary)] rounded-lg focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)] bg-[var(--bg-primary)] text-[var(--text-primary)] resize-none"
/>
</div>
</div>
{/* Validation Message */}
{!isValid && (
<div className="p-3 bg-[var(--color-warning)]/10 border border-[var(--color-warning)]/30 rounded-lg">
<p className="text-sm text-[var(--text-primary)]">
<span className="font-medium"> Campos requeridos:</span> Asegúrate de completar el nombre, producto terminado y categoría.
</p>
</div>
)}
{/* Hidden submit button for form handling */}
<button type="submit" className="hidden" disabled={!isValid} />
</form>
);
};

View File

@@ -0,0 +1,299 @@
import React from 'react';
import { Clock, Settings } from 'lucide-react';
import type { WizardStepProps } from '../../../ui/WizardModal/WizardModal';
import type { RecipeCreate, MeasurementUnit } from '../../../../api/types/recipes';
interface RecipeProductionStepProps extends WizardStepProps {
recipeData: Partial<RecipeCreate>;
onUpdate: (data: Partial<RecipeCreate>) => void;
unitOptions: Array<{ value: MeasurementUnit; label: string }>;
}
export const RecipeProductionStep: React.FC<RecipeProductionStepProps> = ({
recipeData,
onUpdate,
onNext,
onBack,
unitOptions
}) => {
const handleFieldChange = (field: keyof RecipeCreate, value: any) => {
onUpdate({ ...recipeData, [field]: value });
};
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
onNext();
};
const isValid =
recipeData.yield_quantity &&
Number(recipeData.yield_quantity) > 0 &&
recipeData.yield_unit;
return (
<form onSubmit={handleSubmit} className="space-y-6">
{/* Header */}
<div className="flex items-start gap-4 pb-4 border-b border-[var(--border-secondary)]">
<div className="w-12 h-12 rounded-full bg-[var(--color-primary)]/10 flex items-center justify-center flex-shrink-0">
<Clock className="w-6 h-6 text-[var(--color-primary)]" />
</div>
<div className="flex-1">
<h3 className="text-lg font-semibold text-[var(--text-primary)] mb-1">
Detalles de Producción
</h3>
<p className="text-sm text-[var(--text-secondary)]">
Define tiempos, rendimientos y parámetros de producción
</p>
</div>
</div>
<div className="space-y-6">
{/* Yield & Servings Section */}
<div className="space-y-4">
<h4 className="text-sm font-semibold text-[var(--text-primary)] flex items-center gap-2">
<Clock className="w-4 h-4" />
Rendimiento y Porciones
</h4>
<div className="grid grid-cols-3 gap-4">
{/* Yield Quantity */}
<div>
<label className="block text-sm font-medium text-[var(--text-primary)] mb-1.5">
Cantidad <span className="text-red-500">*</span>
</label>
<input
type="number"
value={recipeData.yield_quantity || ''}
onChange={(e) => handleFieldChange('yield_quantity', parseFloat(e.target.value) || 0)}
className="w-full px-4 py-2.5 border border-[var(--border-secondary)] rounded-lg focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)] bg-[var(--bg-primary)] text-[var(--text-primary)]"
min="0.1"
step="0.1"
required
/>
</div>
{/* Yield Unit */}
<div>
<label className="block text-sm font-medium text-[var(--text-primary)] mb-1.5">
Unidad <span className="text-red-500">*</span>
</label>
<select
value={recipeData.yield_unit || 'units'}
onChange={(e) => handleFieldChange('yield_unit', e.target.value as MeasurementUnit)}
className="w-full px-4 py-2.5 border border-[var(--border-secondary)] rounded-lg focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)] bg-[var(--bg-primary)] text-[var(--text-primary)]"
required
>
{unitOptions.map((unit) => (
<option key={unit.value} value={unit.value}>
{unit.label}
</option>
))}
</select>
</div>
{/* Serves Count */}
<div>
<label className="block text-sm font-medium text-[var(--text-primary)] mb-1.5">
Porciones
</label>
<input
type="number"
value={recipeData.serves_count || 1}
onChange={(e) => handleFieldChange('serves_count', parseInt(e.target.value) || 1)}
className="w-full px-4 py-2.5 border border-[var(--border-secondary)] rounded-lg focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)] bg-[var(--bg-primary)] text-[var(--text-primary)]"
min="1"
/>
</div>
</div>
</div>
{/* Time Section */}
<div className="space-y-4">
<h4 className="text-sm font-semibold text-[var(--text-primary)] flex items-center gap-2">
<Clock className="w-4 h-4" />
Tiempos de Preparación
</h4>
<div className="grid grid-cols-3 gap-4">
{/* Prep Time */}
<div>
<label className="block text-sm font-medium text-[var(--text-primary)] mb-1.5">
Preparación (min)
</label>
<input
type="number"
value={recipeData.prep_time_minutes || 0}
onChange={(e) => handleFieldChange('prep_time_minutes', parseInt(e.target.value) || 0)}
className="w-full px-4 py-2.5 border border-[var(--border-secondary)] rounded-lg focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)] bg-[var(--bg-primary)] text-[var(--text-primary)]"
min="0"
/>
<p className="mt-1 text-xs text-[var(--text-tertiary)]">Tiempo de prep. ingredientes</p>
</div>
{/* Cook Time */}
<div>
<label className="block text-sm font-medium text-[var(--text-primary)] mb-1.5">
Cocción (min)
</label>
<input
type="number"
value={recipeData.cook_time_minutes || 0}
onChange={(e) => handleFieldChange('cook_time_minutes', parseInt(e.target.value) || 0)}
className="w-full px-4 py-2.5 border border-[var(--border-secondary)] rounded-lg focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)] bg-[var(--bg-primary)] text-[var(--text-primary)]"
min="0"
/>
<p className="mt-1 text-xs text-[var(--text-tertiary)]">Tiempo de horneado</p>
</div>
{/* Rest Time */}
<div>
<label className="block text-sm font-medium text-[var(--text-primary)] mb-1.5">
Reposo (min)
</label>
<input
type="number"
value={recipeData.rest_time_minutes || 0}
onChange={(e) => handleFieldChange('rest_time_minutes', parseInt(e.target.value) || 0)}
className="w-full px-4 py-2.5 border border-[var(--border-secondary)] rounded-lg focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)] bg-[var(--bg-primary)] text-[var(--text-primary)]"
min="0"
/>
<p className="mt-1 text-xs text-[var(--text-tertiary)]">Tiempo de fermentación</p>
</div>
</div>
{/* Total Time Display */}
{((recipeData.prep_time_minutes || 0) + (recipeData.cook_time_minutes || 0) + (recipeData.rest_time_minutes || 0)) > 0 && (
<div className="p-3 bg-[var(--bg-secondary)] rounded-lg border border-[var(--border-secondary)]">
<p className="text-sm text-[var(--text-primary)]">
<span className="font-medium">Tiempo Total:</span>{' '}
{(recipeData.prep_time_minutes || 0) + (recipeData.cook_time_minutes || 0) + (recipeData.rest_time_minutes || 0)} minutos
({Math.round(((recipeData.prep_time_minutes || 0) + (recipeData.cook_time_minutes || 0) + (recipeData.rest_time_minutes || 0)) / 60 * 10) / 10} horas)
</p>
</div>
)}
</div>
{/* Production Parameters Section */}
<div className="space-y-4">
<h4 className="text-sm font-semibold text-[var(--text-primary)] flex items-center gap-2">
<Settings className="w-4 h-4" />
Parámetros de Producción (Opcional)
</h4>
<div className="grid grid-cols-2 gap-4">
{/* Batch Sizes */}
<div>
<label className="block text-sm font-medium text-[var(--text-primary)] mb-1.5">
Tamaño Mínimo de Lote
</label>
<input
type="number"
value={recipeData.minimum_batch_size || ''}
onChange={(e) => handleFieldChange('minimum_batch_size', e.target.value ? parseInt(e.target.value) : undefined)}
className="w-full px-4 py-2.5 border border-[var(--border-secondary)] rounded-lg focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)] bg-[var(--bg-primary)] text-[var(--text-primary)]"
min="1"
placeholder="Ej: 10"
/>
</div>
<div>
<label className="block text-sm font-medium text-[var(--text-primary)] mb-1.5">
Tamaño Máximo de Lote
</label>
<input
type="number"
value={recipeData.maximum_batch_size || ''}
onChange={(e) => handleFieldChange('maximum_batch_size', e.target.value ? parseInt(e.target.value) : undefined)}
className="w-full px-4 py-2.5 border border-[var(--border-secondary)] rounded-lg focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)] bg-[var(--bg-primary)] text-[var(--text-primary)]"
min="1"
placeholder="Ej: 100"
/>
</div>
{/* Temperature & Humidity */}
<div>
<label className="block text-sm font-medium text-[var(--text-primary)] mb-1.5">
Temperatura Óptima (°C)
</label>
<input
type="number"
value={recipeData.optimal_production_temperature || ''}
onChange={(e) => handleFieldChange('optimal_production_temperature', e.target.value ? parseFloat(e.target.value) : undefined)}
className="w-full px-4 py-2.5 border border-[var(--border-secondary)] rounded-lg focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)] bg-[var(--bg-primary)] text-[var(--text-primary)]"
step="0.1"
placeholder="Ej: 180"
/>
</div>
<div>
<label className="block text-sm font-medium text-[var(--text-primary)] mb-1.5">
Humedad Óptima (%)
</label>
<input
type="number"
value={recipeData.optimal_humidity || ''}
onChange={(e) => handleFieldChange('optimal_humidity', e.target.value ? parseFloat(e.target.value) : undefined)}
className="w-full px-4 py-2.5 border border-[var(--border-secondary)] rounded-lg focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)] bg-[var(--bg-primary)] text-[var(--text-primary)]"
min="0"
max="100"
step="1"
placeholder="Ej: 65"
/>
</div>
</div>
</div>
{/* Special Configuration */}
<div className="space-y-4">
<h4 className="text-sm font-semibold text-[var(--text-primary)]">
Configuración Especial
</h4>
<div className="grid grid-cols-2 gap-4">
{/* Signature Item */}
<label className="flex items-center gap-3 p-3 border border-[var(--border-secondary)] rounded-lg cursor-pointer hover:bg-[var(--bg-secondary)] transition-colors">
<input
type="checkbox"
checked={recipeData.is_signature_item || false}
onChange={(e) => handleFieldChange('is_signature_item', e.target.checked)}
className="w-5 h-5 text-[var(--color-primary)] border-[var(--border-secondary)] rounded focus:ring-[var(--color-primary)]"
/>
<div>
<p className="text-sm font-medium text-[var(--text-primary)]">Receta Estrella</p>
<p className="text-xs text-[var(--text-tertiary)]">Destacar como producto emblema</p>
</div>
</label>
{/* Target Margin */}
<div>
<label className="block text-sm font-medium text-[var(--text-primary)] mb-1.5">
Margen Objetivo (%)
</label>
<input
type="number"
value={recipeData.target_margin_percentage || 30}
onChange={(e) => handleFieldChange('target_margin_percentage', parseFloat(e.target.value) || 30)}
className="w-full px-4 py-2.5 border border-[var(--border-secondary)] rounded-lg focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)] bg-[var(--bg-primary)] text-[var(--text-primary)]"
min="0"
max="100"
step="1"
/>
</div>
</div>
</div>
</div>
{/* Validation Message */}
{!isValid && (
<div className="p-3 bg-[var(--color-warning)]/10 border border-[var(--color-warning)]/30 rounded-lg">
<p className="text-sm text-[var(--text-primary)]">
<span className="font-medium"> Campos requeridos:</span> Asegúrate de completar la cantidad y unidad de rendimiento.
</p>
</div>
)}
{/* Hidden submit button for form handling */}
<button type="submit" className="hidden" disabled={!isValid} />
</form>
);
};

View File

@@ -0,0 +1,234 @@
import React from 'react';
import { FileText, CheckCircle2, Package, Clock, Settings } from 'lucide-react';
import type { WizardStepProps } from '../../../ui/WizardModal/WizardModal';
import type { RecipeCreate } from '../../../../api/types/recipes';
interface RecipeReviewStepProps extends WizardStepProps {
recipeData: Partial<RecipeCreate>;
onUpdate: (data: Partial<RecipeCreate>) => void;
ingredientNames: Map<string, string>;
}
export const RecipeReviewStep: React.FC<RecipeReviewStepProps> = ({
recipeData,
onUpdate,
onNext,
onBack,
ingredientNames
}) => {
const handleFieldChange = (field: keyof RecipeCreate, value: any) => {
onUpdate({ ...recipeData, [field]: value });
};
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
onNext();
};
// Calculate costs if available (future enhancement)
const totalIngredients = recipeData.ingredients?.length || 0;
const validIngredients = recipeData.ingredients?.filter(ing => ing.ingredient_id && ing.quantity > 0).length || 0;
return (
<form onSubmit={handleSubmit} className="space-y-6">
{/* Header */}
<div className="flex items-start gap-4 pb-4 border-b border-[var(--border-secondary)]">
<div className="w-12 h-12 rounded-full bg-[var(--color-primary)]/10 flex items-center justify-center flex-shrink-0">
<FileText className="w-6 h-6 text-[var(--color-primary)]" />
</div>
<div className="flex-1">
<h3 className="text-lg font-semibold text-[var(--text-primary)] mb-1">
Instrucciones y Revisión
</h3>
<p className="text-sm text-[var(--text-secondary)]">
Completa las instrucciones y revisa la receta antes de guardar
</p>
</div>
</div>
{/* Recipe Summary */}
<div className="p-4 bg-gradient-to-r from-[var(--color-primary)]/5 to-[var(--color-primary)]/10 border border-[var(--color-primary)]/20 rounded-lg">
<h4 className="text-sm font-semibold text-[var(--text-primary)] mb-3 flex items-center gap-2">
<CheckCircle2 className="w-4 h-4 text-[var(--color-success)]" />
Resumen de la Receta
</h4>
<div className="grid grid-cols-2 gap-4">
{/* Product Info */}
<div className="space-y-2">
<div className="flex items-start gap-2">
<Package className="w-4 h-4 text-[var(--text-secondary)] mt-0.5 flex-shrink-0" />
<div>
<p className="text-xs text-[var(--text-tertiary)]">Producto</p>
<p className="text-sm font-medium text-[var(--text-primary)]">{recipeData.name || 'Sin nombre'}</p>
</div>
</div>
<div className="flex items-start gap-2">
<Package className="w-4 h-4 text-[var(--text-secondary)] mt-0.5 flex-shrink-0" />
<div>
<p className="text-xs text-[var(--text-tertiary)]">Categoría</p>
<p className="text-sm font-medium text-[var(--text-primary)]">{recipeData.category || 'No especificada'}</p>
</div>
</div>
<div className="flex items-start gap-2">
<Package className="w-4 h-4 text-[var(--text-secondary)] mt-0.5 flex-shrink-0" />
<div>
<p className="text-xs text-[var(--text-tertiary)]">Ingredientes</p>
<p className="text-sm font-medium text-[var(--text-primary)]">{validIngredients} de {totalIngredients}</p>
</div>
</div>
</div>
{/* Production Info */}
<div className="space-y-2">
<div className="flex items-start gap-2">
<Clock className="w-4 h-4 text-[var(--text-secondary)] mt-0.5 flex-shrink-0" />
<div>
<p className="text-xs text-[var(--text-tertiary)]">Tiempo Total</p>
<p className="text-sm font-medium text-[var(--text-primary)]">
{(recipeData.prep_time_minutes || 0) + (recipeData.cook_time_minutes || 0) + (recipeData.rest_time_minutes || 0)} min
</p>
</div>
</div>
<div className="flex items-start gap-2">
<Settings className="w-4 h-4 text-[var(--text-secondary)] mt-0.5 flex-shrink-0" />
<div>
<p className="text-xs text-[var(--text-tertiary)]">Rendimiento</p>
<p className="text-sm font-medium text-[var(--text-primary)]">
{recipeData.yield_quantity || 0} {recipeData.yield_unit || 'unidades'}
</p>
</div>
</div>
<div className="flex items-start gap-2">
<Settings className="w-4 h-4 text-[var(--text-secondary)] mt-0.5 flex-shrink-0" />
<div>
<p className="text-xs text-[var(--text-tertiary)]">Dificultad</p>
<p className="text-sm font-medium text-[var(--text-primary)]">
Nivel {recipeData.difficulty_level || 1}/5
</p>
</div>
</div>
</div>
</div>
{/* Ingredients Preview */}
{recipeData.ingredients && recipeData.ingredients.length > 0 && (
<div className="mt-3 pt-3 border-t border-[var(--border-secondary)]">
<p className="text-xs font-medium text-[var(--text-primary)] mb-2">Lista de Ingredientes:</p>
<div className="flex flex-wrap gap-2">
{recipeData.ingredients.map((ing, idx) => {
const name = ingredientNames.get(ing.ingredient_id) || 'Desconocido';
return (
<span
key={idx}
className="inline-flex items-center gap-1 px-2 py-1 bg-[var(--bg-secondary)] border border-[var(--border-secondary)] rounded text-xs text-[var(--text-primary)]"
>
<span className="font-medium">{name}</span>
<span className="text-[var(--text-tertiary)]">({ing.quantity} {ing.unit})</span>
</span>
);
})}
</div>
</div>
)}
</div>
{/* Instructions Section */}
<div className="space-y-4">
<h4 className="text-sm font-semibold text-[var(--text-primary)]">
Instrucciones de Preparación
</h4>
{/* Preparation Notes */}
<div>
<label className="block text-sm font-medium text-[var(--text-primary)] mb-1.5">
Notas de Preparación
</label>
<textarea
value={recipeData.preparation_notes || ''}
onChange={(e) => handleFieldChange('preparation_notes', e.target.value)}
placeholder="Pasos detallados para la preparación de la receta..."
rows={4}
className="w-full px-4 py-2.5 border border-[var(--border-secondary)] rounded-lg focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)] bg-[var(--bg-primary)] text-[var(--text-primary)] resize-none"
/>
<p className="mt-1 text-xs text-[var(--text-tertiary)]">
Describe el proceso paso a paso
</p>
</div>
{/* Storage Instructions */}
<div>
<label className="block text-sm font-medium text-[var(--text-primary)] mb-1.5">
Instrucciones de Almacenamiento
</label>
<textarea
value={recipeData.storage_instructions || ''}
onChange={(e) => handleFieldChange('storage_instructions', e.target.value)}
placeholder="Cómo conservar el producto terminado..."
rows={2}
className="w-full px-4 py-2.5 border border-[var(--border-secondary)] rounded-lg focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)] bg-[var(--bg-primary)] text-[var(--text-primary)] resize-none"
/>
<p className="mt-1 text-xs text-[var(--text-tertiary)]">
Condiciones de almacenamiento recomendadas
</p>
</div>
</div>
{/* Nutritional Information (Optional) */}
<div className="space-y-4">
<h4 className="text-sm font-semibold text-[var(--text-primary)]">
Información Nutricional (Opcional)
</h4>
<div className="grid grid-cols-1 gap-4">
{/* Allergens */}
<div>
<label className="block text-sm font-medium text-[var(--text-primary)] mb-1.5">
Alérgenos
</label>
<input
type="text"
value={recipeData.allergen_info ? (recipeData.allergen_info as any).allergens?.join(', ') : ''}
onChange={(e) => handleFieldChange('allergen_info', e.target.value ? { allergens: e.target.value.split(',').map((a: string) => a.trim()) } : null)}
placeholder="Ej: Gluten, Lácteos, Huevos"
className="w-full px-4 py-2.5 border border-[var(--border-secondary)] rounded-lg focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)] bg-[var(--bg-primary)] text-[var(--text-primary)]"
/>
<p className="mt-1 text-xs text-[var(--text-tertiary)]">
Separar con comas
</p>
</div>
{/* Dietary Tags */}
<div>
<label className="block text-sm font-medium text-[var(--text-primary)] mb-1.5">
Etiquetas Dietéticas
</label>
<input
type="text"
value={recipeData.dietary_tags ? (recipeData.dietary_tags as any).tags?.join(', ') : ''}
onChange={(e) => handleFieldChange('dietary_tags', e.target.value ? { tags: e.target.value.split(',').map((t: string) => t.trim()) } : null)}
placeholder="Ej: Vegano, Sin gluten, Orgánico"
className="w-full px-4 py-2.5 border border-[var(--border-secondary)] rounded-lg focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)] bg-[var(--bg-primary)] text-[var(--text-primary)]"
/>
<p className="mt-1 text-xs text-[var(--text-tertiary)]">
Separar con comas
</p>
</div>
</div>
</div>
{/* Ready to Save Message */}
<div className="p-4 bg-[var(--color-success)]/10 border border-[var(--color-success)]/30 rounded-lg">
<p className="text-sm text-[var(--text-primary)] flex items-center gap-2">
<CheckCircle2 className="w-4 h-4 text-[var(--color-success)]" />
<span>
<span className="font-medium">¡Listo para guardar!</span> Revisa la información y haz clic en "Completar" para crear la receta.
</span>
</p>
</div>
{/* Hidden submit button for form handling */}
<button type="submit" className="hidden" />
</form>
);
};

View File

@@ -0,0 +1,297 @@
import React, { useState, useMemo } from 'react';
import { ChefHat } from 'lucide-react';
import { WizardModal, WizardStep } from '../../../ui/WizardModal/WizardModal';
import { RecipeProductStep } from './RecipeProductStep';
import { RecipeIngredientsStep } from './RecipeIngredientsStep';
import { RecipeProductionStep } from './RecipeProductionStep';
import { RecipeReviewStep } from './RecipeReviewStep';
import type { RecipeCreate, RecipeIngredientCreate, MeasurementUnit } from '../../../../api/types/recipes';
import { useIngredients } from '../../../../api/hooks/inventory';
import { useCurrentTenant } from '../../../../stores/tenant.store';
interface RecipeWizardModalProps {
isOpen: boolean;
onClose: () => void;
onCreateRecipe: (recipeData: RecipeCreate) => Promise<void>;
}
export const RecipeWizardModal: React.FC<RecipeWizardModalProps> = ({
isOpen,
onClose,
onCreateRecipe
}) => {
// Recipe state
const [recipeData, setRecipeData] = useState<Partial<RecipeCreate>>({
difficulty_level: 1,
yield_quantity: 1,
yield_unit: 'units' as MeasurementUnit,
serves_count: 1,
prep_time_minutes: 0,
cook_time_minutes: 0,
rest_time_minutes: 0,
target_margin_percentage: 30,
batch_size_multiplier: 1.0,
is_seasonal: false,
is_signature_item: false,
ingredients: [{
ingredient_id: '',
quantity: 1,
unit: 'grams' as MeasurementUnit,
ingredient_order: 1,
is_optional: false
}]
});
// Get tenant and fetch data
const currentTenant = useCurrentTenant();
const tenantId = currentTenant?.id || '';
const {
data: inventoryItems = [],
isLoading: inventoryLoading
} = useIngredients(tenantId, {});
// Separate finished products and ingredients
const finishedProducts = useMemo(() =>
(inventoryItems || [])
.filter(item => item.product_type === 'finished_product')
.map(product => ({
value: product.id,
label: `${product.name} (${product.category || 'Sin categoría'})`
})),
[inventoryItems]
);
const availableIngredients = useMemo(() =>
(inventoryItems || [])
.filter(item => item.product_type !== 'finished_product')
.map(ingredient => ({
value: ingredient.id,
label: `${ingredient.name} (${ingredient.category || 'Sin categoría'})`
})),
[inventoryItems]
);
// Create map of ingredient IDs to names for display
const ingredientNames = useMemo(() => {
const map = new Map<string, string>();
inventoryItems.forEach(item => {
map.set(item.id, item.name);
});
return map;
}, [inventoryItems]);
// Options
const categoryOptions = [
{ value: 'bread', label: 'Pan' },
{ value: 'pastry', label: 'Bollería' },
{ value: 'cake', label: 'Tarta' },
{ value: 'cookie', label: 'Galleta' },
{ value: 'muffin', label: 'Muffin' },
{ value: 'savory', label: 'Salado' },
{ value: 'desserts', label: 'Postres' },
{ value: 'specialty', label: 'Especialidad' },
{ value: 'other', label: 'Otro' }
];
const cuisineTypeOptions = [
{ value: 'french', label: 'Francés' },
{ value: 'spanish', label: 'Español' },
{ value: 'italian', label: 'Italiano' },
{ value: 'german', label: 'Alemán' },
{ value: 'american', label: 'Americano' },
{ value: 'artisanal', label: 'Artesanal' },
{ value: 'traditional', label: 'Tradicional' },
{ value: 'modern', label: 'Moderno' }
];
const unitOptions = [
{ value: 'units' as MeasurementUnit, label: 'Unidades' },
{ value: 'pieces' as MeasurementUnit, label: 'Piezas' },
{ value: 'grams' as MeasurementUnit, label: 'Gramos' },
{ value: 'kilograms' as MeasurementUnit, label: 'Kilogramos' },
{ value: 'milliliters' as MeasurementUnit, label: 'Mililitros' },
{ value: 'liters' as MeasurementUnit, label: 'Litros' }
];
const handleUpdate = (data: Partial<RecipeCreate>) => {
setRecipeData(data);
};
const handleComplete = async () => {
try {
// Generate recipe code if not provided
const recipeCode = recipeData.recipe_code ||
(recipeData.name?.substring(0, 3).toUpperCase() || 'RCP') +
String(Date.now()).slice(-3);
// Calculate total time
const totalTime = (recipeData.prep_time_minutes || 0) +
(recipeData.cook_time_minutes || 0) +
(recipeData.rest_time_minutes || 0);
// Filter and validate ingredients
const validIngredients = (recipeData.ingredients || [])
.filter((ing: RecipeIngredientCreate) => ing.ingredient_id && ing.ingredient_id.trim() !== '')
.map((ing: RecipeIngredientCreate, index: number) => ({
...ing,
ingredient_order: index + 1
}));
if (validIngredients.length === 0) {
throw new Error('Debe agregar al menos un ingrediente válido');
}
// Build final recipe data
const finalRecipeData: RecipeCreate = {
name: recipeData.name!,
recipe_code: recipeCode,
version: recipeData.version || '1.0',
finished_product_id: recipeData.finished_product_id!,
description: recipeData.description || '',
category: recipeData.category!,
cuisine_type: recipeData.cuisine_type || '',
difficulty_level: recipeData.difficulty_level!,
yield_quantity: recipeData.yield_quantity!,
yield_unit: recipeData.yield_unit!,
prep_time_minutes: recipeData.prep_time_minutes || 0,
cook_time_minutes: recipeData.cook_time_minutes || 0,
total_time_minutes: totalTime,
rest_time_minutes: recipeData.rest_time_minutes || 0,
target_margin_percentage: recipeData.target_margin_percentage || 30,
instructions: null,
preparation_notes: recipeData.preparation_notes || '',
storage_instructions: recipeData.storage_instructions || '',
quality_check_configuration: null,
serves_count: recipeData.serves_count || 1,
is_seasonal: recipeData.is_seasonal || false,
season_start_month: recipeData.is_seasonal ? recipeData.season_start_month : undefined,
season_end_month: recipeData.is_seasonal ? recipeData.season_end_month : undefined,
is_signature_item: recipeData.is_signature_item || false,
batch_size_multiplier: recipeData.batch_size_multiplier || 1.0,
minimum_batch_size: recipeData.minimum_batch_size,
maximum_batch_size: recipeData.maximum_batch_size,
optimal_production_temperature: recipeData.optimal_production_temperature,
optimal_humidity: recipeData.optimal_humidity,
allergen_info: recipeData.allergen_info || null,
dietary_tags: recipeData.dietary_tags || null,
nutritional_info: recipeData.nutritional_info || null,
ingredients: validIngredients
};
await onCreateRecipe(finalRecipeData);
// Reset state
setRecipeData({
difficulty_level: 1,
yield_quantity: 1,
yield_unit: 'units' as MeasurementUnit,
serves_count: 1,
prep_time_minutes: 0,
cook_time_minutes: 0,
rest_time_minutes: 0,
target_margin_percentage: 30,
batch_size_multiplier: 1.0,
is_seasonal: false,
is_signature_item: false,
ingredients: [{
ingredient_id: '',
quantity: 1,
unit: 'grams' as MeasurementUnit,
ingredient_order: 1,
is_optional: false
}]
});
} catch (error) {
console.error('Error creating recipe:', error);
throw error;
}
};
// Define wizard steps
const steps: WizardStep[] = [
{
id: 'product',
title: 'Información del Producto',
description: 'Configura los datos básicos de la receta',
component: (props) => (
<RecipeProductStep
{...props}
recipeData={recipeData}
onUpdate={handleUpdate}
finishedProducts={finishedProducts}
categoryOptions={categoryOptions}
cuisineTypeOptions={cuisineTypeOptions}
/>
),
validate: () => {
return !!(
recipeData.name &&
recipeData.name.trim().length >= 2 &&
recipeData.finished_product_id &&
recipeData.category
);
}
},
{
id: 'ingredients',
title: 'Ingredientes',
description: 'Agrega los ingredientes necesarios',
component: (props) => (
<RecipeIngredientsStep
{...props}
recipeData={recipeData}
onUpdate={handleUpdate}
availableIngredients={availableIngredients}
unitOptions={unitOptions}
/>
),
validate: () => {
const ingredients = recipeData.ingredients || [];
return ingredients.length > 0 &&
ingredients.some(ing => ing.ingredient_id && ing.ingredient_id.trim() !== '' && ing.quantity > 0);
}
},
{
id: 'production',
title: 'Detalles de Producción',
description: 'Define tiempos y parámetros',
component: (props) => (
<RecipeProductionStep
{...props}
recipeData={recipeData}
onUpdate={handleUpdate}
unitOptions={unitOptions}
/>
),
validate: () => {
return !!(recipeData.yield_quantity && Number(recipeData.yield_quantity) > 0 && recipeData.yield_unit);
}
},
{
id: 'review',
title: 'Instrucciones y Revisión',
description: 'Completa las instrucciones y revisa',
component: (props) => (
<RecipeReviewStep
{...props}
recipeData={recipeData}
onUpdate={handleUpdate}
ingredientNames={ingredientNames}
/>
)
}
];
return (
<WizardModal
isOpen={isOpen}
onClose={onClose}
onComplete={handleComplete}
title="Nueva Receta"
steps={steps}
icon={<ChefHat className="w-6 h-6" />}
size="2xl"
/>
);
};

View File

@@ -0,0 +1,5 @@
export { RecipeWizardModal } from './RecipeWizardModal';
export { RecipeProductStep } from './RecipeProductStep';
export { RecipeIngredientsStep } from './RecipeIngredientsStep';
export { RecipeProductionStep } from './RecipeProductionStep';
export { RecipeReviewStep } from './RecipeReviewStep';