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:
@@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
export { RecipeWizardModal } from './RecipeWizardModal';
|
||||||
|
export { RecipeProductStep } from './RecipeProductStep';
|
||||||
|
export { RecipeIngredientsStep } from './RecipeIngredientsStep';
|
||||||
|
export { RecipeProductionStep } from './RecipeProductionStep';
|
||||||
|
export { RecipeReviewStep } from './RecipeReviewStep';
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
export { SupplierWizardModal } from './SupplierWizardModal';
|
||||||
|
export { SupplierBasicStep } from './SupplierBasicStep';
|
||||||
|
export { SupplierDeliveryStep } from './SupplierDeliveryStep';
|
||||||
|
export { SupplierReviewStep } from './SupplierReviewStep';
|
||||||
271
frontend/src/components/ui/WizardModal/WizardModal.tsx
Normal file
271
frontend/src/components/ui/WizardModal/WizardModal.tsx
Normal 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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
2
frontend/src/components/ui/WizardModal/index.ts
Normal file
2
frontend/src/components/ui/WizardModal/index.ts
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
export { WizardModal } from './WizardModal';
|
||||||
|
export type { WizardStep, WizardStepProps } from './WizardModal';
|
||||||
Reference in New Issue
Block a user