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';

View File

@@ -0,0 +1,191 @@
import React from 'react';
import { Users } from 'lucide-react';
import type { WizardStepProps } from '../../../ui/WizardModal/WizardModal';
import type { SupplierCreate, SupplierType, SupplierStatus, PaymentTerms } from '../../../../api/types/suppliers';
interface SupplierBasicStepProps extends WizardStepProps {
supplierData: Partial<SupplierCreate>;
onUpdate: (data: Partial<SupplierCreate>) => void;
}
export const SupplierBasicStep: React.FC<SupplierBasicStepProps> = ({
supplierData,
onUpdate,
onNext
}) => {
const handleFieldChange = (field: keyof SupplierCreate, value: any) => {
onUpdate({ ...supplierData, [field]: value });
};
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
onNext();
};
const isValid = supplierData.name && supplierData.name.trim().length >= 1 && supplierData.supplier_type;
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">
<Users 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 Básica
</h3>
<p className="text-sm text-[var(--text-secondary)]">
Configura los datos esenciales del proveedor
</p>
</div>
</div>
{/* Form Fields */}
<div className="space-y-5">
{/* Supplier Name */}
<div>
<label className="block text-sm font-medium text-[var(--text-primary)] mb-1.5">
Nombre del Proveedor <span className="text-red-500">*</span>
</label>
<input
type="text"
value={supplierData.name || ''}
onChange={(e) => handleFieldChange('name', e.target.value)}
placeholder="Ej: Molinos La Victoria"
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
/>
</div>
<div className="grid grid-cols-2 gap-4">
{/* Supplier Type */}
<div>
<label className="block text-sm font-medium text-[var(--text-primary)] mb-1.5">
Tipo de Proveedor <span className="text-red-500">*</span>
</label>
<select
value={supplierData.supplier_type || ''}
onChange={(e) => handleFieldChange('supplier_type', e.target.value as SupplierType)}
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...</option>
<option value="ingredients">Ingredientes</option>
<option value="packaging">Empaque</option>
<option value="equipment">Equipo</option>
<option value="utilities">Servicios Públicos</option>
<option value="services">Servicios</option>
<option value="logistics">Logística</option>
<option value="other">Otro</option>
</select>
</div>
{/* Status */}
<div>
<label className="block text-sm font-medium text-[var(--text-primary)] mb-1.5">
Estado <span className="text-red-500">*</span>
</label>
<select
value={supplierData.status || 'pending_approval'}
onChange={(e) => handleFieldChange('status', e.target.value as SupplierStatus)}
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="pending_approval">Pendiente de Aprobación</option>
<option value="active">Activo</option>
<option value="inactive">Inactivo</option>
<option value="suspended">Suspendido</option>
<option value="terminated">Terminado</option>
<option value="evaluation">En Evaluación</option>
</select>
</div>
</div>
{/* Contact Information */}
<div className="grid grid-cols-1 gap-4">
<div>
<label className="block text-sm font-medium text-[var(--text-primary)] mb-1.5">
Persona de Contacto
</label>
<input
type="text"
value={supplierData.contact_person || ''}
onChange={(e) => handleFieldChange('contact_person', e.target.value)}
placeholder="Nombre del representante"
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)]"
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-[var(--text-primary)] mb-1.5">
Email
</label>
<input
type="email"
value={supplierData.email || ''}
onChange={(e) => handleFieldChange('email', e.target.value)}
placeholder="correo@ejemplo.com"
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)]"
/>
</div>
<div>
<label className="block text-sm font-medium text-[var(--text-primary)] mb-1.5">
Teléfono
</label>
<input
type="tel"
value={supplierData.phone || ''}
onChange={(e) => handleFieldChange('phone', e.target.value)}
placeholder="+34 600 000 000"
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)]"
/>
</div>
</div>
</div>
{/* Tax & Registration */}
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-[var(--text-primary)] mb-1.5">
NIF/CIF (Opcional)
</label>
<input
type="text"
value={supplierData.tax_id || ''}
onChange={(e) => handleFieldChange('tax_id', e.target.value)}
placeholder="Ej: B12345678"
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)]"
/>
</div>
<div>
<label className="block text-sm font-medium text-[var(--text-primary)] mb-1.5">
Número de Registro (Opcional)
</label>
<input
type="text"
value={supplierData.registration_number || ''}
onChange={(e) => handleFieldChange('registration_number', e.target.value)}
placeholder="Número de registro oficial"
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)]"
/>
</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 el nombre y tipo de proveedor.
</p>
</div>
)}
{/* Hidden submit button for form handling */}
<button type="submit" className="hidden" disabled={!isValid} />
</form>
);
};

View File

@@ -0,0 +1,198 @@
import React from 'react';
import { Truck } from 'lucide-react';
import type { WizardStepProps } from '../../../ui/WizardModal/WizardModal';
import type { SupplierCreate, PaymentTerms, DeliverySchedule } from '../../../../api/types/suppliers';
interface SupplierDeliveryStepProps extends WizardStepProps {
supplierData: Partial<SupplierCreate>;
onUpdate: (data: Partial<SupplierCreate>) => void;
}
export const SupplierDeliveryStep: React.FC<SupplierDeliveryStepProps> = ({
supplierData,
onUpdate,
onNext,
onBack
}) => {
const handleFieldChange = (field: keyof SupplierCreate, value: any) => {
onUpdate({ ...supplierData, [field]: value });
};
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
onNext();
};
const isValid = supplierData.payment_terms;
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">
<Truck 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">
Entrega y Términos
</h3>
<p className="text-sm text-[var(--text-secondary)]">
Define términos de pago, entrega y ubicación
</p>
</div>
</div>
{/* Form Fields */}
<div className="space-y-5">
{/* Payment Terms & Lead Time */}
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-[var(--text-primary)] mb-1.5">
Términos de Pago <span className="text-red-500">*</span>
</label>
<select
value={supplierData.payment_terms || ''}
onChange={(e) => handleFieldChange('payment_terms', e.target.value as PaymentTerms)}
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...</option>
<option value="immediate">Inmediato</option>
<option value="net_7">Neto 7 días</option>
<option value="net_15">Neto 15 días</option>
<option value="net_30">Neto 30 días</option>
<option value="net_60">Neto 60 días</option>
<option value="net_90">Neto 90 días</option>
<option value="cod">Contra reembolso</option>
<option value="cia">Efectivo por adelantado</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-[var(--text-primary)] mb-1.5">
Tiempo de Entrega (días)
</label>
<input
type="number"
value={supplierData.lead_time_days || ''}
onChange={(e) => handleFieldChange('lead_time_days', e.target.value ? parseInt(e.target.value) : undefined)}
placeholder="Ej: 3"
min="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)]"
/>
</div>
</div>
{/* Minimum Order & Delivery Schedule */}
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-[var(--text-primary)] mb-1.5">
Pedido Mínimo ()
</label>
<input
type="number"
value={supplierData.minimum_order_value || ''}
onChange={(e) => handleFieldChange('minimum_order_value', e.target.value ? parseFloat(e.target.value) : undefined)}
placeholder="Ej: 100"
min="0"
step="0.01"
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)]"
/>
</div>
<div>
<label className="block text-sm font-medium text-[var(--text-primary)] mb-1.5">
Frecuencia de Entrega
</label>
<select
value={supplierData.delivery_schedule || ''}
onChange={(e) => handleFieldChange('delivery_schedule', e.target.value as DeliverySchedule)}
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...</option>
<option value="daily">Diario</option>
<option value="weekly">Semanal</option>
<option value="biweekly">Quincenal</option>
<option value="monthly">Mensual</option>
<option value="on_demand">Bajo demanda</option>
<option value="custom">Personalizado</option>
</select>
</div>
</div>
{/* Address Section */}
<div className="space-y-4 pt-4 border-t border-[var(--border-secondary)]">
<h4 className="text-sm font-semibold text-[var(--text-primary)]">
Dirección del Proveedor (Opcional)
</h4>
<div>
<label className="block text-sm font-medium text-[var(--text-primary)] mb-1.5">
Calle y Número
</label>
<input
type="text"
value={supplierData.address_street || ''}
onChange={(e) => handleFieldChange('address_street', e.target.value)}
placeholder="Ej: Calle Mayor, 123"
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)]"
/>
</div>
<div className="grid grid-cols-3 gap-4">
<div>
<label className="block text-sm font-medium text-[var(--text-primary)] mb-1.5">
Ciudad
</label>
<input
type="text"
value={supplierData.address_city || ''}
onChange={(e) => handleFieldChange('address_city', e.target.value)}
placeholder="Ej: Madrid"
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)]"
/>
</div>
<div>
<label className="block text-sm font-medium text-[var(--text-primary)] mb-1.5">
Código Postal
</label>
<input
type="text"
value={supplierData.address_postal_code || ''}
onChange={(e) => handleFieldChange('address_postal_code', e.target.value)}
placeholder="Ej: 28001"
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)]"
/>
</div>
<div>
<label className="block text-sm font-medium text-[var(--text-primary)] mb-1.5">
País
</label>
<input
type="text"
value={supplierData.address_country || ''}
onChange={(e) => handleFieldChange('address_country', e.target.value)}
placeholder="Ej: España"
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)]"
/>
</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"> Campo requerido:</span> Selecciona los términos de pago.
</p>
</div>
)}
{/* Hidden submit button for form handling */}
<button type="submit" className="hidden" disabled={!isValid} />
</form>
);
};

View File

@@ -0,0 +1,225 @@
import React from 'react';
import { FileText, CheckCircle2, Users, Truck, Award } from 'lucide-react';
import type { WizardStepProps } from '../../../ui/WizardModal/WizardModal';
import type { SupplierCreate } from '../../../../api/types/suppliers';
interface SupplierReviewStepProps extends WizardStepProps {
supplierData: Partial<SupplierCreate>;
onUpdate: (data: Partial<SupplierCreate>) => void;
}
export const SupplierReviewStep: React.FC<SupplierReviewStepProps> = ({
supplierData,
onUpdate,
onNext,
onBack
}) => {
const handleFieldChange = (field: keyof SupplierCreate, value: any) => {
onUpdate({ ...supplierData, [field]: value });
};
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
onNext();
};
const getSupplierTypeLabel = (type?: string) => {
const labels: Record<string, string> = {
'ingredients': 'Ingredientes',
'packaging': 'Empaque',
'equipment': 'Equipo',
'utilities': 'Servicios Públicos',
'services': 'Servicios',
'logistics': 'Logística',
'other': 'Otro'
};
return labels[type || ''] || type || 'No especificado';
};
const getPaymentTermsLabel = (terms?: string) => {
const labels: Record<string, string> = {
'immediate': 'Inmediato',
'net_7': 'Neto 7 días',
'net_15': 'Neto 15 días',
'net_30': 'Neto 30 días',
'net_60': 'Neto 60 días',
'net_90': 'Neto 90 días',
'cod': 'Contra reembolso',
'cia': 'Efectivo por adelantado'
};
return labels[terms || ''] || terms || 'No especificado';
};
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">
Detalles Adicionales y Revisión
</h3>
<p className="text-sm text-[var(--text-secondary)]">
Completa información adicional y revisa el proveedor
</p>
</div>
</div>
{/* Supplier 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 del Proveedor
</h4>
<div className="grid grid-cols-2 gap-4">
{/* Basic Info */}
<div className="space-y-2">
<div className="flex items-start gap-2">
<Users className="w-4 h-4 text-[var(--text-secondary)] mt-0.5 flex-shrink-0" />
<div>
<p className="text-xs text-[var(--text-tertiary)]">Proveedor</p>
<p className="text-sm font-medium text-[var(--text-primary)]">{supplierData.name || 'Sin nombre'}</p>
</div>
</div>
<div className="flex items-start gap-2">
<Users className="w-4 h-4 text-[var(--text-secondary)] mt-0.5 flex-shrink-0" />
<div>
<p className="text-xs text-[var(--text-tertiary)]">Tipo</p>
<p className="text-sm font-medium text-[var(--text-primary)]">
{getSupplierTypeLabel(supplierData.supplier_type)}
</p>
</div>
</div>
{supplierData.contact_person && (
<div className="flex items-start gap-2">
<Users className="w-4 h-4 text-[var(--text-secondary)] mt-0.5 flex-shrink-0" />
<div>
<p className="text-xs text-[var(--text-tertiary)]">Contacto</p>
<p className="text-sm font-medium text-[var(--text-primary)]">{supplierData.contact_person}</p>
</div>
</div>
)}
</div>
{/* Delivery Info */}
<div className="space-y-2">
<div className="flex items-start gap-2">
<Truck className="w-4 h-4 text-[var(--text-secondary)] mt-0.5 flex-shrink-0" />
<div>
<p className="text-xs text-[var(--text-tertiary)]">Términos de Pago</p>
<p className="text-sm font-medium text-[var(--text-primary)]">
{getPaymentTermsLabel(supplierData.payment_terms)}
</p>
</div>
</div>
{supplierData.lead_time_days && (
<div className="flex items-start gap-2">
<Truck 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 de Entrega</p>
<p className="text-sm font-medium text-[var(--text-primary)]">{supplierData.lead_time_days} días</p>
</div>
</div>
)}
{supplierData.minimum_order_value && (
<div className="flex items-start gap-2">
<Truck className="w-4 h-4 text-[var(--text-secondary)] mt-0.5 flex-shrink-0" />
<div>
<p className="text-xs text-[var(--text-tertiary)]">Pedido Mínimo</p>
<p className="text-sm font-medium text-[var(--text-primary)]">{supplierData.minimum_order_value}</p>
</div>
</div>
)}
</div>
</div>
</div>
{/* Additional Details */}
<div className="space-y-4">
<h4 className="text-sm font-semibold text-[var(--text-primary)] flex items-center gap-2">
<Award className="w-4 h-4" />
Información Adicional (Opcional)
</h4>
{/* Certifications */}
<div>
<label className="block text-sm font-medium text-[var(--text-primary)] mb-1.5">
Certificaciones
</label>
<input
type="text"
value={
supplierData.certifications
? typeof supplierData.certifications === 'string'
? supplierData.certifications
: JSON.stringify(supplierData.certifications)
: ''
}
onChange={(e) => {
const value = e.target.value;
handleFieldChange('certifications', value ? { certs: value.split(',').map(c => c.trim()) } : null);
}}
placeholder="Ej: ISO 9001, HACCP, 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>
{/* Sustainability Practices */}
<div>
<label className="block text-sm font-medium text-[var(--text-primary)] mb-1.5">
Prácticas de Sostenibilidad
</label>
<textarea
value={
supplierData.sustainability_practices
? typeof supplierData.sustainability_practices === 'string'
? supplierData.sustainability_practices
: JSON.stringify(supplierData.sustainability_practices)
: ''
}
onChange={(e) => {
const value = e.target.value;
handleFieldChange('sustainability_practices', value ? { practices: value } : null);
}}
placeholder="Describe las prácticas sostenibles del proveedor..."
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"
/>
</div>
{/* Notes */}
<div>
<label className="block text-sm font-medium text-[var(--text-primary)] mb-1.5">
Notas
</label>
<textarea
value={supplierData.notes || ''}
onChange={(e) => handleFieldChange('notes', e.target.value)}
placeholder="Notas adicionales sobre el proveedor..."
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>
{/* 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 el proveedor.
</span>
</p>
</div>
{/* Hidden submit button for form handling */}
<button type="submit" className="hidden" />
</form>
);
};

View File

@@ -0,0 +1,155 @@
import React, { useState } from 'react';
import { Users } from 'lucide-react';
import { WizardModal, WizardStep } from '../../../ui/WizardModal/WizardModal';
import { SupplierBasicStep } from './SupplierBasicStep';
import { SupplierDeliveryStep } from './SupplierDeliveryStep';
import { SupplierReviewStep } from './SupplierReviewStep';
import type { SupplierCreate, SupplierType, SupplierStatus, PaymentTerms } from '../../../../api/types/suppliers';
interface SupplierWizardModalProps {
isOpen: boolean;
onClose: () => void;
onCreateSupplier: (supplierData: SupplierCreate) => Promise<void>;
}
export const SupplierWizardModal: React.FC<SupplierWizardModalProps> = ({
isOpen,
onClose,
onCreateSupplier
}) => {
// Supplier state
const [supplierData, setSupplierData] = useState<Partial<SupplierCreate>>({
status: 'pending_approval' as SupplierStatus,
payment_terms: 'net_30' as PaymentTerms,
quality_rating: 0,
delivery_rating: 0,
pricing_rating: 0,
overall_rating: 0
});
const handleUpdate = (data: Partial<SupplierCreate>) => {
setSupplierData(data);
};
const handleComplete = async () => {
try {
// Generate supplier code if not provided
const supplierCode = supplierData.supplier_code ||
(supplierData.name?.substring(0, 3).toUpperCase() || 'SUP') +
String(Date.now()).slice(-3);
// Build final supplier data
const finalSupplierData: SupplierCreate = {
name: supplierData.name!,
supplier_code: supplierCode,
tax_id: supplierData.tax_id || null,
registration_number: supplierData.registration_number || null,
supplier_type: supplierData.supplier_type!,
status: supplierData.status || ('pending_approval' as SupplierStatus),
contact_person: supplierData.contact_person || null,
email: supplierData.email || null,
phone: supplierData.phone || null,
website: supplierData.website || null,
address_street: supplierData.address_street || null,
address_city: supplierData.address_city || null,
address_state: supplierData.address_state || null,
address_postal_code: supplierData.address_postal_code || null,
address_country: supplierData.address_country || null,
payment_terms: supplierData.payment_terms!,
credit_limit: supplierData.credit_limit || null,
currency: supplierData.currency || 'EUR',
tax_rate: supplierData.tax_rate || null,
lead_time_days: supplierData.lead_time_days || null,
minimum_order_value: supplierData.minimum_order_value || null,
delivery_schedule: supplierData.delivery_schedule || null,
preferred_delivery_method: supplierData.preferred_delivery_method || null,
bank_account: supplierData.bank_account || null,
bank_name: supplierData.bank_name || null,
swift_code: supplierData.swift_code || null,
certifications: supplierData.certifications || null,
quality_rating: supplierData.quality_rating || 0,
delivery_rating: supplierData.delivery_rating || 0,
pricing_rating: supplierData.pricing_rating || 0,
overall_rating: supplierData.overall_rating || 0,
sustainability_practices: supplierData.sustainability_practices || null,
insurance_info: supplierData.insurance_info || null,
notes: supplierData.notes || null,
business_hours: supplierData.business_hours || null,
specializations: supplierData.specializations || null
};
await onCreateSupplier(finalSupplierData);
// Reset state
setSupplierData({
status: 'pending_approval' as SupplierStatus,
payment_terms: 'net_30' as PaymentTerms,
quality_rating: 0,
delivery_rating: 0,
pricing_rating: 0,
overall_rating: 0
});
} catch (error) {
console.error('Error creating supplier:', error);
throw error;
}
};
// Define wizard steps
const steps: WizardStep[] = [
{
id: 'basic',
title: 'Información Básica',
description: 'Datos esenciales del proveedor',
component: (props) => (
<SupplierBasicStep
{...props}
supplierData={supplierData}
onUpdate={handleUpdate}
/>
),
validate: () => {
return !!(supplierData.name && supplierData.name.trim().length >= 1 && supplierData.supplier_type);
}
},
{
id: 'delivery',
title: 'Entrega y Términos',
description: 'Términos de pago y entrega',
component: (props) => (
<SupplierDeliveryStep
{...props}
supplierData={supplierData}
onUpdate={handleUpdate}
/>
),
validate: () => {
return !!supplierData.payment_terms;
}
},
{
id: 'review',
title: 'Revisión',
description: 'Detalles adicionales y revisión',
component: (props) => (
<SupplierReviewStep
{...props}
supplierData={supplierData}
onUpdate={handleUpdate}
/>
)
}
];
return (
<WizardModal
isOpen={isOpen}
onClose={onClose}
onComplete={handleComplete}
title="Nuevo Proveedor"
steps={steps}
icon={<Users className="w-6 h-6" />}
size="xl"
/>
);
};

View File

@@ -0,0 +1,4 @@
export { SupplierWizardModal } from './SupplierWizardModal';
export { SupplierBasicStep } from './SupplierBasicStep';
export { SupplierDeliveryStep } from './SupplierDeliveryStep';
export { SupplierReviewStep } from './SupplierReviewStep';

View File

@@ -0,0 +1,271 @@
import React, { useState, useCallback } from 'react';
import { X, ChevronLeft, ChevronRight } from 'lucide-react';
export interface WizardStep {
id: string;
title: string;
description?: string;
component: React.ComponentType<WizardStepProps>;
isOptional?: boolean;
validate?: () => Promise<boolean> | boolean;
}
export interface WizardStepProps {
onNext: () => void;
onBack: () => void;
onComplete: () => void;
isFirstStep: boolean;
isLastStep: boolean;
currentStepIndex: number;
totalSteps: number;
goToStep: (index: number) => void;
}
interface WizardModalProps {
isOpen: boolean;
onClose: () => void;
onComplete: (data?: any) => void;
title: string;
steps: WizardStep[];
icon?: React.ReactNode;
size?: 'sm' | 'md' | 'lg' | 'xl' | '2xl';
}
export const WizardModal: React.FC<WizardModalProps> = ({
isOpen,
onClose,
onComplete,
title,
steps,
icon,
size = 'xl'
}) => {
const [currentStepIndex, setCurrentStepIndex] = useState(0);
const [isValidating, setIsValidating] = useState(false);
const currentStep = steps[currentStepIndex];
const isFirstStep = currentStepIndex === 0;
const isLastStep = currentStepIndex === steps.length - 1;
const sizeClasses = {
sm: 'max-w-md',
md: 'max-w-2xl',
lg: 'max-w-4xl',
xl: 'max-w-5xl',
'2xl': 'max-w-7xl'
};
const handleNext = useCallback(async () => {
// Validate current step if validator exists
if (currentStep.validate) {
setIsValidating(true);
try {
const isValid = await currentStep.validate();
if (!isValid) {
setIsValidating(false);
return;
}
} catch (error) {
console.error('Validation error:', error);
setIsValidating(false);
return;
}
setIsValidating(false);
}
if (isLastStep) {
handleComplete();
} else {
setCurrentStepIndex(prev => Math.min(prev + 1, steps.length - 1));
}
}, [currentStep, isLastStep, steps.length]);
const handleBack = useCallback(() => {
setCurrentStepIndex(prev => Math.max(prev - 1, 0));
}, []);
const handleComplete = useCallback(() => {
onComplete();
handleClose();
}, [onComplete]);
const handleClose = useCallback(() => {
setCurrentStepIndex(0);
onClose();
}, [onClose]);
const goToStep = useCallback((index: number) => {
if (index >= 0 && index < steps.length) {
setCurrentStepIndex(index);
}
}, [steps.length]);
if (!isOpen) return null;
const StepComponent = currentStep.component;
const progressPercentage = ((currentStepIndex + 1) / steps.length) * 100;
return (
<>
{/* Backdrop */}
<div
className="fixed inset-0 bg-black/50 z-50 animate-fadeIn"
onClick={handleClose}
/>
{/* Modal */}
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 pointer-events-none">
<div
className={`bg-[var(--bg-primary)] rounded-xl shadow-2xl ${sizeClasses[size]} w-full max-h-[90vh] overflow-hidden pointer-events-auto animate-slideUp`}
onClick={(e) => e.stopPropagation()}
>
{/* Header */}
<div className="sticky top-0 z-10 bg-[var(--bg-primary)] border-b border-[var(--border-secondary)]">
{/* Title Bar */}
<div className="flex items-center justify-between p-6 pb-4">
<div className="flex items-center gap-3">
{icon && (
<div className="w-10 h-10 rounded-full bg-[var(--color-primary)]/10 flex items-center justify-center text-[var(--color-primary)]">
{icon}
</div>
)}
<div>
<h2 className="text-xl font-semibold text-[var(--text-primary)]">
{title}
</h2>
<p className="text-sm text-[var(--text-secondary)] mt-0.5">
{currentStep.description || `Step ${currentStepIndex + 1} of ${steps.length}`}
</p>
</div>
</div>
<button
onClick={handleClose}
className="p-2 text-[var(--text-secondary)] hover:text-[var(--text-primary)] hover:bg-[var(--bg-secondary)] rounded-lg transition-colors"
>
<X className="w-5 h-5" />
</button>
</div>
{/* Progress Bar */}
<div className="px-6 pb-4">
<div className="flex items-center gap-2 mb-2">
{steps.map((step, index) => (
<React.Fragment key={step.id}>
<button
onClick={() => index < currentStepIndex && goToStep(index)}
disabled={index > currentStepIndex}
className={`flex-1 h-2 rounded-full transition-all duration-300 ${
index < currentStepIndex
? 'bg-[var(--color-success)] cursor-pointer hover:bg-[var(--color-success)]/80'
: index === currentStepIndex
? 'bg-[var(--color-primary)]'
: 'bg-[var(--bg-tertiary)] cursor-not-allowed'
}`}
title={step.title}
/>
</React.Fragment>
))}
</div>
<div className="flex items-center justify-between text-xs text-[var(--text-secondary)]">
<span className="font-medium">{currentStep.title}</span>
<span>{currentStepIndex + 1} / {steps.length}</span>
</div>
</div>
</div>
{/* Step Content */}
<div className="p-6 overflow-y-auto" style={{ maxHeight: 'calc(90vh - 220px)' }}>
<StepComponent
onNext={handleNext}
onBack={handleBack}
onComplete={handleComplete}
isFirstStep={isFirstStep}
isLastStep={isLastStep}
currentStepIndex={currentStepIndex}
totalSteps={steps.length}
goToStep={goToStep}
/>
</div>
{/* Footer Navigation */}
<div className="sticky bottom-0 border-t border-[var(--border-secondary)] bg-[var(--bg-secondary)]/50 backdrop-blur-sm px-6 py-4">
<div className="flex items-center justify-between gap-3">
{/* Back Button */}
{!isFirstStep && (
<button
onClick={handleBack}
disabled={isValidating}
className="px-4 py-2.5 text-[var(--text-secondary)] hover:text-[var(--text-primary)] hover:bg-[var(--bg-primary)] rounded-lg transition-colors font-medium disabled:opacity-50 disabled:cursor-not-allowed inline-flex items-center gap-2"
>
<ChevronLeft className="w-4 h-4" />
Back
</button>
)}
<div className="flex-1" />
{/* Skip Button (for optional steps) */}
{currentStep.isOptional && !isLastStep && (
<button
onClick={() => setCurrentStepIndex(prev => prev + 1)}
disabled={isValidating}
className="px-4 py-2.5 text-[var(--text-secondary)] hover:text-[var(--text-primary)] hover:bg-[var(--bg-primary)] rounded-lg transition-colors font-medium disabled:opacity-50"
>
Skip This Step
</button>
)}
{/* Next/Complete Button */}
<button
onClick={handleNext}
disabled={isValidating}
className="px-6 py-2.5 bg-[var(--color-primary)] text-white rounded-lg hover:bg-[var(--color-primary-dark)] disabled:opacity-50 disabled:cursor-not-allowed transition-colors font-medium inline-flex items-center gap-2 min-w-[140px] justify-center"
>
{isValidating ? (
<>
<div className="w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin" />
Validating...
</>
) : isLastStep ? (
<>
Complete
<ChevronRight className="w-4 h-4" />
</>
) : (
<>
Next
<ChevronRight className="w-4 h-4" />
</>
)}
</button>
</div>
</div>
</div>
</div>
{/* Animation Styles */}
<style>{`
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes slideUp {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.animate-fadeIn {
animation: fadeIn 0.2s ease-out;
}
.animate-slideUp {
animation: slideUp 0.3s ease-out;
}
`}</style>
</>
);
};

View File

@@ -0,0 +1,2 @@
export { WizardModal } from './WizardModal';
export type { WizardStep, WizardStepProps } from './WizardModal';