Implement Phase 2: Core data entry steps for setup wizard

This commit implements the three core data entry steps for the bakery
setup wizard, enabling users to configure their essential operational data
immediately after onboarding.

## Implemented Steps

### 1. Suppliers Setup Step (SuppliersSetupStep.tsx)
- Inline form for adding/editing suppliers
- Required fields: name, supplier_type
- Optional fields: contact_person, phone, email
- List view with edit/delete actions
- Minimum requirement: 1 supplier
- Real-time validation and error handling
- Integration with existing suppliers API hooks

### 2. Inventory Setup Step (InventorySetupStep.tsx)
- Inline form for adding/editing ingredients
- Required fields: name, category, unit_of_measure
- Optional fields: brand, standard_cost
- List view with edit/delete actions (scrollable)
- Minimum requirement: 3 ingredients
- Progress indicator showing remaining items needed
- Category and unit dropdowns with i18n support

### 3. Recipes Setup Step (RecipesSetupStep.tsx)
- Recipe creation form with ingredient management
- Required fields: name, finished_product, yield_quantity, yield_unit
- Dynamic ingredient list (add/remove ingredients)
- Prerequisite check (requires ≥2 inventory items)
- Per-ingredient validation (ingredient_id, quantity)
- Minimum requirement: 1 recipe
- Integration with recipes and inventory APIs

## Key Features

### Shared Functionality Across All Steps:
- Parent notification via onUpdate callback (itemsCount, canContinue)
- Inline forms (not modals) for better UX flow
- Real-time validation with error messages
- Loading states and empty states
- Responsive design (mobile-first)
- i18n support with translation keys
- Delete confirmation dialogs
- "Why This Matters" sections explaining value

### Progress Tracking:
- Progress indicators showing count and requirement status
- Visual feedback when minimum requirements met
- "Need X more" messages for incomplete steps

### Error Handling:
- Field-level validation errors
- Type-safe number inputs
- Required field indicators
- User-friendly error messages

## Technical Implementation

### API Integration:
- Uses existing React Query hooks pattern
- Proper cache invalidation on mutations
- Tenant-scoped queries
- Optimistic updates where applicable

### State Management:
- Local form state for each step
- useEffect for parent updates
- Reset functionality on cancel/success

### Type Safety:
- TypeScript interfaces for all data
- Enum types for categories and units
- Proper typing for mutation callbacks

## Files Modified:
- frontend/src/components/domain/setup-wizard/steps/SuppliersSetupStep.tsx
- frontend/src/components/domain/setup-wizard/steps/InventorySetupStep.tsx
- frontend/src/components/domain/setup-wizard/steps/RecipesSetupStep.tsx

## Related:
- Builds on Phase 1 wizard foundation
- Integrates with existing suppliers, inventory, and recipes services
- Follows design specification in docs/wizard-flow-specification.md
- Addresses JTBD analysis findings in docs/jtbd-analysis-inventory-setup.md

## Next Steps (Phase 3):
- Quality Setup Step
- Team Setup Step
- Template systems
- Bulk import functionality
This commit is contained in:
Claude
2025-11-06 11:26:41 +00:00
parent 2e3d89bd7b
commit ec4a440cb1
3 changed files with 1319 additions and 43 deletions

View File

@@ -1,12 +1,184 @@
import React from 'react'; import React, { useState, useEffect } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import type { SetupStepProps } from '../SetupWizard'; import type { SetupStepProps } from '../SetupWizard';
import { useIngredients, useCreateIngredient, useUpdateIngredient, useSoftDeleteIngredient } from '../../../../api/hooks/inventory';
import { useCurrentTenant } from '../../../../stores/tenant.store';
import { useAuthUser } from '../../../../stores/auth.store';
import { UnitOfMeasure, IngredientCategory } from '../../../../api/types/inventory';
import type { IngredientCreate, IngredientUpdate } from '../../../../api/types/inventory';
export const InventorySetupStep: React.FC<SetupStepProps> = () => { export const InventorySetupStep: React.FC<SetupStepProps> = ({ onUpdate }) => {
const { t } = useTranslation(); const { t } = useTranslation();
// Get tenant ID
const currentTenant = useCurrentTenant();
const user = useAuthUser();
const tenantId = currentTenant?.id || user?.tenant_id || '';
// Fetch ingredients
const { data: ingredientsData, isLoading } = useIngredients(tenantId);
const ingredients = ingredientsData || [];
// Mutations
const createIngredientMutation = useCreateIngredient();
const updateIngredientMutation = useUpdateIngredient();
const deleteIngredientMutation = useSoftDeleteIngredient();
// Form state
const [isAdding, setIsAdding] = useState(false);
const [editingId, setEditingId] = useState<string | null>(null);
const [formData, setFormData] = useState({
name: '',
category: IngredientCategory.OTHER,
unit_of_measure: UnitOfMeasure.KILOGRAMS,
brand: '',
standard_cost: '',
low_stock_threshold: '',
});
const [errors, setErrors] = useState<Record<string, string>>({});
// Notify parent when count changes
useEffect(() => {
const count = ingredients.length;
onUpdate?.({
itemsCount: count,
canContinue: count >= 3,
});
}, [ingredients.length, onUpdate]);
// Validation
const validateForm = (): boolean => {
const newErrors: Record<string, string> = {};
if (!formData.name.trim()) {
newErrors.name = t('setup_wizard:inventory.errors.name_required', 'Name is required');
}
if (formData.standard_cost && isNaN(Number(formData.standard_cost))) {
newErrors.standard_cost = t('setup_wizard:inventory.errors.cost_invalid', 'Cost must be a valid number');
}
if (formData.low_stock_threshold && isNaN(Number(formData.low_stock_threshold))) {
newErrors.low_stock_threshold = t('setup_wizard:inventory.errors.threshold_invalid', 'Threshold must be a valid number');
}
setErrors(newErrors);
return Object.keys(newErrors).length === 0;
};
// Form handlers
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!validateForm()) return;
try {
if (editingId) {
// Update existing ingredient
await updateIngredientMutation.mutateAsync({
tenantId,
ingredientId: editingId,
updateData: {
name: formData.name,
category: formData.category,
unit_of_measure: formData.unit_of_measure,
brand: formData.brand || null,
standard_cost: formData.standard_cost ? Number(formData.standard_cost) : null,
low_stock_threshold: formData.low_stock_threshold ? Number(formData.low_stock_threshold) : null,
} as IngredientUpdate,
});
setEditingId(null);
} else {
// Create new ingredient
await createIngredientMutation.mutateAsync({
tenantId,
ingredientData: {
name: formData.name,
category: formData.category,
unit_of_measure: formData.unit_of_measure,
brand: formData.brand || undefined,
standard_cost: formData.standard_cost ? Number(formData.standard_cost) : undefined,
low_stock_threshold: formData.low_stock_threshold ? Number(formData.low_stock_threshold) : undefined,
} as IngredientCreate,
});
}
// Reset form
resetForm();
} catch (error) {
console.error('Error saving ingredient:', error);
}
};
const resetForm = () => {
setFormData({
name: '',
category: IngredientCategory.OTHER,
unit_of_measure: UnitOfMeasure.KILOGRAMS,
brand: '',
standard_cost: '',
low_stock_threshold: '',
});
setErrors({});
setIsAdding(false);
setEditingId(null);
};
const handleEdit = (ingredient: any) => {
setFormData({
name: ingredient.name,
category: ingredient.category || IngredientCategory.OTHER,
unit_of_measure: ingredient.unit_of_measure,
brand: ingredient.brand || '',
standard_cost: ingredient.standard_cost?.toString() || '',
low_stock_threshold: ingredient.low_stock_threshold?.toString() || '',
});
setEditingId(ingredient.id);
setIsAdding(true);
};
const handleDelete = async (ingredientId: string) => {
if (!window.confirm(t('setup_wizard:inventory.confirm_delete', 'Are you sure you want to delete this ingredient?'))) {
return;
}
try {
await deleteIngredientMutation.mutateAsync({ tenantId, ingredientId });
} catch (error) {
console.error('Error deleting ingredient:', error);
}
};
const categoryOptions = [
{ value: IngredientCategory.FLOUR, label: t('inventory:category.flour', 'Flour') },
{ value: IngredientCategory.YEAST, label: t('inventory:category.yeast', 'Yeast') },
{ value: IngredientCategory.DAIRY, label: t('inventory:category.dairy', 'Dairy') },
{ value: IngredientCategory.EGGS, label: t('inventory:category.eggs', 'Eggs') },
{ value: IngredientCategory.SUGAR, label: t('inventory:category.sugar', 'Sugar') },
{ value: IngredientCategory.FATS, label: t('inventory:category.fats', 'Fats/Oils') },
{ value: IngredientCategory.SALT, label: t('inventory:category.salt', 'Salt') },
{ value: IngredientCategory.SPICES, label: t('inventory:category.spices', 'Spices') },
{ value: IngredientCategory.ADDITIVES, label: t('inventory:category.additives', 'Additives') },
{ value: IngredientCategory.PACKAGING, label: t('inventory:category.packaging', 'Packaging') },
{ value: IngredientCategory.CLEANING, label: t('inventory:category.cleaning', 'Cleaning') },
{ value: IngredientCategory.OTHER, label: t('inventory:category.other', 'Other') },
];
const unitOptions = [
{ value: UnitOfMeasure.KILOGRAMS, label: t('inventory:unit.kg', 'Kilograms (kg)') },
{ value: UnitOfMeasure.GRAMS, label: t('inventory:unit.g', 'Grams (g)') },
{ value: UnitOfMeasure.LITERS, label: t('inventory:unit.l', 'Liters (l)') },
{ value: UnitOfMeasure.MILLILITERS, label: t('inventory:unit.ml', 'Milliliters (ml)') },
{ value: UnitOfMeasure.UNITS, label: t('inventory:unit.units', 'Units') },
{ value: UnitOfMeasure.PIECES, label: t('inventory:unit.pcs', 'Pieces') },
{ value: UnitOfMeasure.PACKAGES, label: t('inventory:unit.pkg', 'Packages') },
{ value: UnitOfMeasure.BAGS, label: t('inventory:unit.bags', 'Bags') },
{ value: UnitOfMeasure.BOXES, label: t('inventory:unit.boxes', 'Boxes') },
];
return ( return (
<div className="space-y-6"> <div className="space-y-6">
{/* Why This Matters */}
<div className="bg-[var(--color-info)]/10 border border-[var(--color-info)]/20 rounded-lg p-4"> <div className="bg-[var(--color-info)]/10 border border-[var(--color-info)]/20 rounded-lg p-4">
<h3 className="font-semibold text-[var(--text-primary)] mb-2 flex items-center gap-2"> <h3 className="font-semibold text-[var(--text-primary)] mb-2 flex items-center gap-2">
<svg className="w-5 h-5 text-[var(--color-info)]" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg className="w-5 h-5 text-[var(--color-info)]" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@@ -19,20 +191,257 @@ export const InventorySetupStep: React.FC<SetupStepProps> = () => {
</p> </p>
</div> </div>
<div className="text-center py-12 border-2 border-dashed border-[var(--border-secondary)] rounded-lg"> {/* Progress indicator */}
<div className="w-16 h-16 bg-[var(--bg-secondary)] rounded-full mx-auto mb-4 flex items-center justify-center"> <div className="flex items-center justify-between p-3 bg-[var(--bg-secondary)] rounded-lg">
📦 <div className="flex items-center gap-2">
<svg className="w-5 h-5 text-[var(--text-secondary)]" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4" />
</svg>
<span className="text-sm font-medium text-[var(--text-primary)]">
{t('setup_wizard:inventory.added_count', { count: ingredients.length, defaultValue: '{{count}} ingredient added' })}
</span>
</div> </div>
<h3 className="text-lg font-semibold text-[var(--text-primary)] mb-2"> {ingredients.length >= 3 ? (
{t('setup_wizard:inventory.placeholder_title', 'Inventory Management')} <div className="flex items-center gap-1 text-xs text-[var(--color-success)]">
</h3> <svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<p className="text-[var(--text-secondary)] mb-4"> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
{t('setup_wizard:inventory.placeholder_desc', 'This feature will be implemented in Phase 2')} </svg>
</p> {t('setup_wizard:inventory.minimum_met', 'Minimum requirement met')}
<p className="text-sm text-[var(--text-tertiary)]"> </div>
{t('setup_wizard:inventory.min_required', 'Minimum required: 3 inventory items')} ) : (
</p> <div className="text-xs text-[var(--text-secondary)]">
{t('setup_wizard:inventory.need_more', 'Need {{count}} more', { count: 3 - ingredients.length })}
</div>
)}
</div> </div>
{/* Ingredients list */}
{ingredients.length > 0 && (
<div className="space-y-2">
<h4 className="text-sm font-medium text-[var(--text-secondary)]">
{t('setup_wizard:inventory.your_ingredients', 'Your Ingredients')}
</h4>
<div className="space-y-2 max-h-96 overflow-y-auto">
{ingredients.map((ingredient) => (
<div
key={ingredient.id}
className="flex items-center justify-between p-3 bg-[var(--bg-secondary)] border border-[var(--border-secondary)] rounded-lg hover:border-[var(--border-primary)] transition-colors"
>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<h5 className="font-medium text-[var(--text-primary)] truncate">{ingredient.name}</h5>
{ingredient.brand && (
<span className="text-xs text-[var(--text-tertiary)]">({ingredient.brand})</span>
)}
</div>
<div className="flex items-center gap-3 mt-1 text-xs text-[var(--text-secondary)]">
<span className="px-2 py-0.5 bg-[var(--bg-primary)] rounded-full">
{categoryOptions.find(opt => opt.value === ingredient.category)?.label || ingredient.category}
</span>
<span>{ingredient.unit_of_measure}</span>
{ingredient.standard_cost && (
<span className="flex items-center gap-1">
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V7m0 1v8m0 0v1m0-1c-1.11 0-2.08-.402-2.599-1M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
${Number(ingredient.standard_cost).toFixed(2)}
</span>
)}
</div>
</div>
<div className="flex items-center gap-2 ml-4">
<button
type="button"
onClick={() => handleEdit(ingredient)}
className="p-1.5 text-[var(--text-secondary)] hover:text-[var(--color-primary)] hover:bg-[var(--bg-primary)] rounded transition-colors"
aria-label={t('common:edit', 'Edit')}
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
</svg>
</button>
<button
type="button"
onClick={() => handleDelete(ingredient.id)}
className="p-1.5 text-[var(--text-secondary)] hover:text-[var(--color-error)] hover:bg-[var(--color-error)]/10 rounded transition-colors"
aria-label={t('common:delete', 'Delete')}
disabled={deleteIngredientMutation.isPending}
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
</button>
</div>
</div>
))}
</div>
</div>
)}
{/* Add/Edit form */}
{isAdding ? (
<form onSubmit={handleSubmit} className="space-y-4 p-4 border-2 border-[var(--color-primary)] rounded-lg bg-[var(--bg-secondary)]">
<div className="flex items-center justify-between mb-2">
<h4 className="font-medium text-[var(--text-primary)]">
{editingId ? t('setup_wizard:inventory.edit_ingredient', 'Edit Ingredient') : t('setup_wizard:inventory.add_ingredient', 'Add Ingredient')}
</h4>
<button
type="button"
onClick={resetForm}
className="text-sm text-[var(--text-secondary)] hover:text-[var(--text-primary)]"
>
{t('common:cancel', 'Cancel')}
</button>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{/* Name */}
<div className="md:col-span-2">
<label htmlFor="ingredient-name" className="block text-sm font-medium text-[var(--text-primary)] mb-1">
{t('setup_wizard:inventory.fields.name', 'Ingredient Name')} <span className="text-[var(--color-error)]">*</span>
</label>
<input
id="ingredient-name"
type="text"
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
className={`w-full px-3 py-2 bg-[var(--bg-primary)] border ${errors.name ? 'border-[var(--color-error)]' : 'border-[var(--border-secondary)]'} rounded-lg focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)] text-[var(--text-primary)]`}
placeholder={t('setup_wizard:inventory.placeholders.name', 'e.g., Harina 000, Levadura fresca')}
/>
{errors.name && <p className="mt-1 text-xs text-[var(--color-error)]">{errors.name}</p>}
</div>
{/* Category */}
<div>
<label htmlFor="category" className="block text-sm font-medium text-[var(--text-primary)] mb-1">
{t('setup_wizard:inventory.fields.category', 'Category')} <span className="text-[var(--color-error)]">*</span>
</label>
<select
id="category"
value={formData.category}
onChange={(e) => setFormData({ ...formData, category: e.target.value as IngredientCategory })}
className="w-full px-3 py-2 bg-[var(--bg-primary)] border border-[var(--border-secondary)] rounded-lg focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)] text-[var(--text-primary)]"
>
{categoryOptions.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</div>
{/* Unit of Measure */}
<div>
<label htmlFor="unit" className="block text-sm font-medium text-[var(--text-primary)] mb-1">
{t('setup_wizard:inventory.fields.unit', 'Unit of Measure')} <span className="text-[var(--color-error)]">*</span>
</label>
<select
id="unit"
value={formData.unit_of_measure}
onChange={(e) => setFormData({ ...formData, unit_of_measure: e.target.value as UnitOfMeasure })}
className="w-full px-3 py-2 bg-[var(--bg-primary)] border border-[var(--border-secondary)] rounded-lg focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)] text-[var(--text-primary)]"
>
{unitOptions.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</div>
{/* Brand */}
<div>
<label htmlFor="brand" className="block text-sm font-medium text-[var(--text-primary)] mb-1">
{t('setup_wizard:inventory.fields.brand', 'Brand')}
</label>
<input
id="brand"
type="text"
value={formData.brand}
onChange={(e) => setFormData({ ...formData, brand: e.target.value })}
className="w-full px-3 py-2 bg-[var(--bg-primary)] border border-[var(--border-secondary)] rounded-lg focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)] text-[var(--text-primary)]"
placeholder={t('setup_wizard:inventory.placeholders.brand', 'e.g., Molinos Río')}
/>
</div>
{/* Standard Cost */}
<div>
<label htmlFor="cost" className="block text-sm font-medium text-[var(--text-primary)] mb-1">
{t('setup_wizard:inventory.fields.cost', 'Standard Cost')}
</label>
<input
id="cost"
type="number"
step="0.01"
value={formData.standard_cost}
onChange={(e) => setFormData({ ...formData, standard_cost: e.target.value })}
className={`w-full px-3 py-2 bg-[var(--bg-primary)] border ${errors.standard_cost ? 'border-[var(--color-error)]' : 'border-[var(--border-secondary)]'} rounded-lg focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)] text-[var(--text-primary)]`}
placeholder={t('setup_wizard:inventory.placeholders.cost', 'e.g., 150.00')}
/>
{errors.standard_cost && <p className="mt-1 text-xs text-[var(--color-error)]">{errors.standard_cost}</p>}
</div>
</div>
<div className="flex gap-2 pt-2">
<button
type="submit"
disabled={createIngredientMutation.isPending || updateIngredientMutation.isPending}
className="px-4 py-2 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"
>
{(createIngredientMutation.isPending || updateIngredientMutation.isPending) ? (
<span className="flex items-center gap-2">
<svg className="animate-spin h-4 w-4" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
</svg>
{t('common:saving', 'Saving...')}
</span>
) : editingId ? (
t('common:update', 'Update')
) : (
t('common:add', 'Add')
)}
</button>
<button
type="button"
onClick={resetForm}
className="px-4 py-2 text-[var(--text-secondary)] hover:text-[var(--text-primary)] hover:bg-[var(--bg-primary)] rounded-lg transition-colors"
>
{t('common:cancel', 'Cancel')}
</button>
</div>
</form>
) : (
<button
type="button"
onClick={() => setIsAdding(true)}
className="w-full p-4 border-2 border-dashed border-[var(--border-secondary)] rounded-lg hover:border-[var(--color-primary)] hover:bg-[var(--bg-secondary)] transition-colors group"
>
<div className="flex items-center justify-center gap-2 text-[var(--text-secondary)] group-hover:text-[var(--color-primary)]">
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 6v6m0 0v6m0-6h6m-6 0H6" />
</svg>
<span className="font-medium">
{ingredients.length === 0
? t('setup_wizard:inventory.add_first', 'Add Your First Ingredient')
: t('setup_wizard:inventory.add_another', 'Add Another Ingredient')}
</span>
</div>
</button>
)}
{/* Loading state */}
{isLoading && ingredients.length === 0 && (
<div className="text-center py-8">
<svg className="animate-spin h-8 w-8 text-[var(--color-primary)] mx-auto" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
</svg>
<p className="mt-2 text-sm text-[var(--text-secondary)]">
{t('common:loading', 'Loading...')}
</p>
</div>
)}
</div> </div>
); );
}; };

View File

@@ -1,12 +1,190 @@
import React from 'react'; import React, { useState, useEffect } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import type { SetupStepProps } from '../SetupWizard'; import type { SetupStepProps } from '../SetupWizard';
import { useRecipes, useCreateRecipe, useUpdateRecipe, useDeleteRecipe } from '../../../../api/hooks/recipes';
import { useIngredients } from '../../../../api/hooks/inventory';
import { useCurrentTenant } from '../../../../stores/tenant.store';
import { useAuthUser } from '../../../../stores/auth.store';
import { MeasurementUnit } from '../../../../api/types/recipes';
import type { RecipeCreate, RecipeIngredientCreate } from '../../../../api/types/recipes';
export const RecipesSetupStep: React.FC<SetupStepProps> = () => { interface RecipeIngredientForm {
ingredient_id: string;
quantity: string;
unit: MeasurementUnit;
ingredient_order: number;
}
export const RecipesSetupStep: React.FC<SetupStepProps> = ({ onUpdate }) => {
const { t } = useTranslation(); const { t } = useTranslation();
// Get tenant ID
const currentTenant = useCurrentTenant();
const user = useAuthUser();
const tenantId = currentTenant?.id || user?.tenant_id || '';
// Fetch recipes and ingredients
const { data: recipesData, isLoading: recipesLoading } = useRecipes(tenantId);
const { data: ingredientsData, isLoading: ingredientsLoading } = useIngredients(tenantId);
const recipes = recipesData || [];
const ingredients = ingredientsData || [];
// Mutations
const createRecipeMutation = useCreateRecipe(tenantId);
const updateRecipeMutation = useUpdateRecipe(tenantId);
const deleteRecipeMutation = useDeleteRecipe(tenantId);
// Form state
const [isAdding, setIsAdding] = useState(false);
const [formData, setFormData] = useState({
name: '',
description: '',
finished_product_id: '',
yield_quantity: '',
yield_unit: MeasurementUnit.UNITS,
category: '',
});
const [recipeIngredients, setRecipeIngredients] = useState<RecipeIngredientForm[]>([]);
const [errors, setErrors] = useState<Record<string, string>>({});
// Notify parent when count changes
useEffect(() => {
const count = recipes.length;
onUpdate?.({
itemsCount: count,
canContinue: count >= 1,
});
}, [recipes.length, onUpdate]);
// Validation
const validateForm = (): boolean => {
const newErrors: Record<string, string> = {};
if (!formData.name.trim()) {
newErrors.name = t('setup_wizard:recipes.errors.name_required', 'Recipe name is required');
}
if (!formData.finished_product_id) {
newErrors.finished_product_id = t('setup_wizard:recipes.errors.finished_product_required', 'Finished product is required');
}
if (!formData.yield_quantity || isNaN(Number(formData.yield_quantity)) || Number(formData.yield_quantity) <= 0) {
newErrors.yield_quantity = t('setup_wizard:recipes.errors.yield_invalid', 'Yield must be a positive number');
}
if (recipeIngredients.length === 0) {
newErrors.ingredients = t('setup_wizard:recipes.errors.ingredients_required', 'At least one ingredient is required');
}
// Validate each ingredient
recipeIngredients.forEach((ing, index) => {
if (!ing.ingredient_id) {
newErrors[`ingredient_${index}_id`] = t('setup_wizard:recipes.errors.ingredient_required', 'Ingredient is required');
}
if (!ing.quantity || isNaN(Number(ing.quantity)) || Number(ing.quantity) <= 0) {
newErrors[`ingredient_${index}_quantity`] = t('setup_wizard:recipes.errors.quantity_invalid', 'Quantity must be positive');
}
});
setErrors(newErrors);
return Object.keys(newErrors).length === 0;
};
// Form handlers
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!validateForm()) return;
try {
const recipeData: RecipeCreate = {
name: formData.name,
description: formData.description || undefined,
finished_product_id: formData.finished_product_id,
yield_quantity: Number(formData.yield_quantity),
yield_unit: formData.yield_unit,
category: formData.category || undefined,
ingredients: recipeIngredients.map((ing) => ({
ingredient_id: ing.ingredient_id,
quantity: Number(ing.quantity),
unit: ing.unit,
ingredient_order: ing.ingredient_order,
is_optional: false,
} as RecipeIngredientCreate)),
};
await createRecipeMutation.mutateAsync(recipeData);
// Reset form
resetForm();
} catch (error) {
console.error('Error saving recipe:', error);
}
};
const resetForm = () => {
setFormData({
name: '',
description: '',
finished_product_id: '',
yield_quantity: '',
yield_unit: MeasurementUnit.UNITS,
category: '',
});
setRecipeIngredients([]);
setErrors({});
setIsAdding(false);
};
const handleDelete = async (recipeId: string) => {
if (!window.confirm(t('setup_wizard:recipes.confirm_delete', 'Are you sure you want to delete this recipe?'))) {
return;
}
try {
await deleteRecipeMutation.mutateAsync(recipeId);
} catch (error) {
console.error('Error deleting recipe:', error);
}
};
const addIngredient = () => {
setRecipeIngredients([
...recipeIngredients,
{
ingredient_id: '',
quantity: '',
unit: MeasurementUnit.GRAMS,
ingredient_order: recipeIngredients.length + 1,
},
]);
};
const removeIngredient = (index: number) => {
setRecipeIngredients(recipeIngredients.filter((_, i) => i !== index));
};
const updateIngredient = (index: number, field: keyof RecipeIngredientForm, value: any) => {
const updated = [...recipeIngredients];
updated[index] = { ...updated[index], [field]: value };
setRecipeIngredients(updated);
};
const unitOptions = [
{ value: MeasurementUnit.GRAMS, label: t('recipes:unit.g', 'Grams (g)') },
{ value: MeasurementUnit.KILOGRAMS, label: t('recipes:unit.kg', 'Kilograms (kg)') },
{ value: MeasurementUnit.MILLILITERS, label: t('recipes:unit.ml', 'Milliliters (ml)') },
{ value: MeasurementUnit.LITERS, label: t('recipes:unit.l', 'Liters (l)') },
{ value: MeasurementUnit.UNITS, label: t('recipes:unit.units', 'Units') },
{ value: MeasurementUnit.PIECES, label: t('recipes:unit.pieces', 'Pieces') },
{ value: MeasurementUnit.CUPS, label: t('recipes:unit.cups', 'Cups') },
{ value: MeasurementUnit.TABLESPOONS, label: t('recipes:unit.tbsp', 'Tablespoons') },
{ value: MeasurementUnit.TEASPOONS, label: t('recipes:unit.tsp', 'Teaspoons') },
];
return ( return (
<div className="space-y-6"> <div className="space-y-6">
{/* Why This Matters */}
<div className="bg-[var(--color-info)]/10 border border-[var(--color-info)]/20 rounded-lg p-4"> <div className="bg-[var(--color-info)]/10 border border-[var(--color-info)]/20 rounded-lg p-4">
<h3 className="font-semibold text-[var(--text-primary)] mb-2 flex items-center gap-2"> <h3 className="font-semibold text-[var(--text-primary)] mb-2 flex items-center gap-2">
<svg className="w-5 h-5 text-[var(--color-info)]" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg className="w-5 h-5 text-[var(--color-info)]" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@@ -19,20 +197,328 @@ export const RecipesSetupStep: React.FC<SetupStepProps> = () => {
</p> </p>
</div> </div>
<div className="text-center py-12 border-2 border-dashed border-[var(--border-secondary)] rounded-lg"> {/* Prerequisites check */}
<div className="w-16 h-16 bg-[var(--bg-secondary)] rounded-full mx-auto mb-4 flex items-center justify-center"> {ingredients.length < 2 && !ingredientsLoading && (
👨🍳 <div className="bg-[var(--color-warning)]/10 border border-[var(--color-warning)]/20 rounded-lg p-4">
<div className="flex items-start gap-2">
<svg className="w-5 h-5 text-[var(--color-warning)] flex-shrink-0 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
<div>
<h4 className="font-medium text-[var(--text-primary)] mb-1">
{t('setup_wizard:recipes.prerequisites_title', 'More ingredients needed')}
</h4>
<p className="text-sm text-[var(--text-secondary)]">
{t('setup_wizard:recipes.prerequisites_desc', 'You need at least 2 ingredients in your inventory before creating recipes. Go back to the Inventory step to add more ingredients.')}
</p>
</div>
</div>
</div> </div>
<h3 className="text-lg font-semibold text-[var(--text-primary)] mb-2"> )}
{t('setup_wizard:recipes.placeholder_title', 'Recipes Management')}
</h3> {/* Progress indicator */}
<p className="text-[var(--text-secondary)] mb-4"> <div className="flex items-center justify-between p-3 bg-[var(--bg-secondary)] rounded-lg">
{t('setup_wizard:recipes.placeholder_desc', 'This feature will be implemented in Phase 2')} <div className="flex items-center gap-2">
</p> <svg className="w-5 h-5 text-[var(--text-secondary)]" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<p className="text-sm text-[var(--text-tertiary)]"> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
{t('setup_wizard:recipes.min_required', 'Minimum required: 1 recipe')} </svg>
</p> <span className="text-sm font-medium text-[var(--text-primary)]">
{t('setup_wizard:recipes.added_count', { count: recipes.length, defaultValue: '{{count}} recipe added' })}
</span>
</div>
{recipes.length >= 1 && (
<div className="flex items-center gap-1 text-xs text-[var(--color-success)]">
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
{t('setup_wizard:recipes.minimum_met', 'Minimum requirement met')}
</div>
)}
</div> </div>
{/* Recipes list */}
{recipes.length > 0 && (
<div className="space-y-2">
<h4 className="text-sm font-medium text-[var(--text-secondary)]">
{t('setup_wizard:recipes.your_recipes', 'Your Recipes')}
</h4>
<div className="space-y-2 max-h-80 overflow-y-auto">
{recipes.map((recipe) => (
<div
key={recipe.id}
className="flex items-center justify-between p-3 bg-[var(--bg-secondary)] border border-[var(--border-secondary)] rounded-lg hover:border-[var(--border-primary)] transition-colors"
>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<h5 className="font-medium text-[var(--text-primary)] truncate">{recipe.name}</h5>
{recipe.category && (
<span className="text-xs px-2 py-0.5 bg-[var(--bg-primary)] rounded-full text-[var(--text-secondary)]">
{recipe.category}
</span>
)}
</div>
<div className="flex items-center gap-3 mt-1 text-xs text-[var(--text-secondary)]">
<span>
{t('setup_wizard:recipes.yield_label', 'Yield')}: {recipe.yield_quantity} {recipe.yield_unit}
</span>
{recipe.estimated_cost_per_unit && (
<span className="flex items-center gap-1">
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V7m0 1v8m0 0v1m0-1c-1.11 0-2.08-.402-2.599-1M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
${Number(recipe.estimated_cost_per_unit).toFixed(2)}/unit
</span>
)}
</div>
</div>
<div className="flex items-center gap-2 ml-4">
<button
type="button"
onClick={() => handleDelete(recipe.id)}
className="p-1.5 text-[var(--text-secondary)] hover:text-[var(--color-error)] hover:bg-[var(--color-error)]/10 rounded transition-colors"
aria-label={t('common:delete', 'Delete')}
disabled={deleteRecipeMutation.isPending}
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
</button>
</div>
</div>
))}
</div>
</div>
)}
{/* Add form */}
{isAdding ? (
<form onSubmit={handleSubmit} className="space-y-4 p-4 border-2 border-[var(--color-primary)] rounded-lg bg-[var(--bg-secondary)]">
<div className="flex items-center justify-between mb-2">
<h4 className="font-medium text-[var(--text-primary)]">
{t('setup_wizard:recipes.add_recipe', 'Add Recipe')}
</h4>
<button
type="button"
onClick={resetForm}
className="text-sm text-[var(--text-secondary)] hover:text-[var(--text-primary)]"
>
{t('common:cancel', 'Cancel')}
</button>
</div>
<div className="space-y-4">
{/* Recipe Name */}
<div>
<label htmlFor="recipe-name" className="block text-sm font-medium text-[var(--text-primary)] mb-1">
{t('setup_wizard:recipes.fields.name', 'Recipe Name')} <span className="text-[var(--color-error)]">*</span>
</label>
<input
id="recipe-name"
type="text"
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
className={`w-full px-3 py-2 bg-[var(--bg-primary)] border ${errors.name ? 'border-[var(--color-error)]' : 'border-[var(--border-secondary)]'} rounded-lg focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)] text-[var(--text-primary)]`}
placeholder={t('setup_wizard:recipes.placeholders.name', 'e.g., Baguette, Croissant')}
/>
{errors.name && <p className="mt-1 text-xs text-[var(--color-error)]">{errors.name}</p>}
</div>
{/* Finished Product */}
<div>
<label htmlFor="finished-product" className="block text-sm font-medium text-[var(--text-primary)] mb-1">
{t('setup_wizard:recipes.fields.finished_product', 'Finished Product')} <span className="text-[var(--color-error)]">*</span>
</label>
<select
id="finished-product"
value={formData.finished_product_id}
onChange={(e) => setFormData({ ...formData, finished_product_id: e.target.value })}
className={`w-full px-3 py-2 bg-[var(--bg-primary)] border ${errors.finished_product_id ? 'border-[var(--color-error)]' : 'border-[var(--border-secondary)]'} rounded-lg focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)] text-[var(--text-primary)]`}
>
<option value="">{t('setup_wizard:recipes.placeholders.finished_product', 'Select finished product...')}</option>
{ingredients.map((ing) => (
<option key={ing.id} value={ing.id}>
{ing.name} ({ing.unit_of_measure})
</option>
))}
</select>
{errors.finished_product_id && <p className="mt-1 text-xs text-[var(--color-error)]">{errors.finished_product_id}</p>}
</div>
{/* Yield */}
<div className="grid grid-cols-2 gap-4">
<div>
<label htmlFor="yield-quantity" className="block text-sm font-medium text-[var(--text-primary)] mb-1">
{t('setup_wizard:recipes.fields.yield_quantity', 'Yield Quantity')} <span className="text-[var(--color-error)]">*</span>
</label>
<input
id="yield-quantity"
type="number"
step="0.01"
value={formData.yield_quantity}
onChange={(e) => setFormData({ ...formData, yield_quantity: e.target.value })}
className={`w-full px-3 py-2 bg-[var(--bg-primary)] border ${errors.yield_quantity ? 'border-[var(--color-error)]' : 'border-[var(--border-secondary)]'} rounded-lg focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)] text-[var(--text-primary)]`}
placeholder="10"
/>
{errors.yield_quantity && <p className="mt-1 text-xs text-[var(--color-error)]">{errors.yield_quantity}</p>}
</div>
<div>
<label htmlFor="yield-unit" className="block text-sm font-medium text-[var(--text-primary)] mb-1">
{t('setup_wizard:recipes.fields.yield_unit', 'Unit')} <span className="text-[var(--color-error)]">*</span>
</label>
<select
id="yield-unit"
value={formData.yield_unit}
onChange={(e) => setFormData({ ...formData, yield_unit: e.target.value as MeasurementUnit })}
className="w-full px-3 py-2 bg-[var(--bg-primary)] border border-[var(--border-secondary)] rounded-lg focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)] text-[var(--text-primary)]"
>
{unitOptions.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</div>
</div>
{/* Ingredients */}
<div>
<div className="flex items-center justify-between mb-2">
<label className="block text-sm font-medium text-[var(--text-primary)]">
{t('setup_wizard:recipes.fields.ingredients', 'Ingredients')} <span className="text-[var(--color-error)]">*</span>
</label>
<button
type="button"
onClick={addIngredient}
className="text-xs text-[var(--color-primary)] hover:underline"
>
+ {t('setup_wizard:recipes.add_ingredient', 'Add Ingredient')}
</button>
</div>
{errors.ingredients && <p className="mb-2 text-xs text-[var(--color-error)]">{errors.ingredients}</p>}
<div className="space-y-2">
{recipeIngredients.map((ing, index) => (
<div key={index} className="flex gap-2 items-start p-2 bg-[var(--bg-primary)] rounded-lg">
<div className="flex-1">
<select
value={ing.ingredient_id}
onChange={(e) => updateIngredient(index, 'ingredient_id', e.target.value)}
className={`w-full px-2 py-1.5 text-sm bg-[var(--bg-secondary)] border ${errors[`ingredient_${index}_id`] ? 'border-[var(--color-error)]' : 'border-[var(--border-secondary)]'} rounded focus:outline-none focus:ring-1 focus:ring-[var(--color-primary)] text-[var(--text-primary)]`}
>
<option value="">{t('setup_wizard:recipes.select_ingredient', 'Select...')}</option>
{ingredients.map((ingredient) => (
<option key={ingredient.id} value={ingredient.id}>
{ingredient.name}
</option>
))}
</select>
{errors[`ingredient_${index}_id`] && <p className="mt-1 text-xs text-[var(--color-error)]">{errors[`ingredient_${index}_id`]}</p>}
</div>
<div className="w-24">
<input
type="number"
step="0.01"
value={ing.quantity}
onChange={(e) => updateIngredient(index, 'quantity', e.target.value)}
placeholder="Qty"
className={`w-full px-2 py-1.5 text-sm bg-[var(--bg-secondary)] border ${errors[`ingredient_${index}_quantity`] ? 'border-[var(--color-error)]' : 'border-[var(--border-secondary)]'} rounded focus:outline-none focus:ring-1 focus:ring-[var(--color-primary)] text-[var(--text-primary)]`}
/>
{errors[`ingredient_${index}_quantity`] && <p className="mt-1 text-xs text-[var(--color-error)]">{errors[`ingredient_${index}_quantity`]}</p>}
</div>
<div className="w-20">
<select
value={ing.unit}
onChange={(e) => updateIngredient(index, 'unit', e.target.value)}
className="w-full px-2 py-1.5 text-sm bg-[var(--bg-secondary)] border border-[var(--border-secondary)] rounded focus:outline-none focus:ring-1 focus:ring-[var(--color-primary)] text-[var(--text-primary)]"
>
{unitOptions.map((option) => (
<option key={option.value} value={option.value}>
{option.value}
</option>
))}
</select>
</div>
<button
type="button"
onClick={() => removeIngredient(index)}
className="p-1.5 text-[var(--text-secondary)] hover:text-[var(--color-error)] rounded"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
))}
{recipeIngredients.length === 0 && (
<div className="text-center py-4 text-sm text-[var(--text-tertiary)] border border-dashed border-[var(--border-secondary)] rounded-lg">
{t('setup_wizard:recipes.no_ingredients', 'No ingredients added yet')}
</div>
)}
</div>
</div>
</div>
<div className="flex gap-2 pt-2">
<button
type="submit"
disabled={createRecipeMutation.isPending}
className="px-4 py-2 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"
>
{createRecipeMutation.isPending ? (
<span className="flex items-center gap-2">
<svg className="animate-spin h-4 w-4" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
</svg>
{t('common:saving', 'Saving...')}
</span>
) : (
t('common:add', 'Add')
)}
</button>
<button
type="button"
onClick={resetForm}
className="px-4 py-2 text-[var(--text-secondary)] hover:text-[var(--text-primary)] hover:bg-[var(--bg-primary)] rounded-lg transition-colors"
>
{t('common:cancel', 'Cancel')}
</button>
</div>
</form>
) : (
<button
type="button"
onClick={() => setIsAdding(true)}
disabled={ingredients.length < 2}
className="w-full p-4 border-2 border-dashed border-[var(--border-secondary)] rounded-lg hover:border-[var(--color-primary)] hover:bg-[var(--bg-secondary)] transition-colors group disabled:opacity-50 disabled:cursor-not-allowed"
>
<div className="flex items-center justify-center gap-2 text-[var(--text-secondary)] group-hover:text-[var(--color-primary)]">
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 6v6m0 0v6m0-6h6m-6 0H6" />
</svg>
<span className="font-medium">
{recipes.length === 0
? t('setup_wizard:recipes.add_first', 'Add Your First Recipe')
: t('setup_wizard:recipes.add_another', 'Add Another Recipe')}
</span>
</div>
</button>
)}
{/* Loading state */}
{(recipesLoading || ingredientsLoading) && recipes.length === 0 && (
<div className="text-center py-8">
<svg className="animate-spin h-8 w-8 text-[var(--color-primary)] mx-auto" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
</svg>
<p className="mt-2 text-sm text-[var(--text-secondary)]">
{t('common:loading', 'Loading...')}
</p>
</div>
)}
</div> </div>
); );
}; };

View File

@@ -1,10 +1,154 @@
import React from 'react'; import React, { useState, useEffect } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import type { SetupStepProps } from '../SetupWizard'; import type { SetupStepProps } from '../SetupWizard';
import { useSuppliers, useCreateSupplier, useUpdateSupplier, useDeleteSupplier } from '../../../../api/hooks/suppliers';
import { useCurrentTenant } from '../../../../stores/tenant.store';
import { useAuthUser } from '../../../../stores/auth.store';
import { SupplierType } from '../../../../api/types/suppliers';
import type { SupplierCreate, SupplierUpdate } from '../../../../api/types/suppliers';
export const SuppliersSetupStep: React.FC<SetupStepProps> = () => { export const SuppliersSetupStep: React.FC<SetupStepProps> = ({ onUpdate }) => {
const { t } = useTranslation(); const { t } = useTranslation();
// Get tenant ID
const currentTenant = useCurrentTenant();
const user = useAuthUser();
const tenantId = currentTenant?.id || user?.tenant_id || '';
// Fetch suppliers
const { data: suppliersData, isLoading } = useSuppliers(tenantId);
const suppliers = suppliersData || [];
// Mutations
const createSupplierMutation = useCreateSupplier();
const updateSupplierMutation = useUpdateSupplier();
const deleteSupplierMutation = useDeleteSupplier();
// Form state
const [isAdding, setIsAdding] = useState(false);
const [editingId, setEditingId] = useState<string | null>(null);
const [formData, setFormData] = useState({
name: '',
supplier_type: 'ingredients' as SupplierType,
contact_person: '',
phone: '',
email: '',
});
const [errors, setErrors] = useState<Record<string, string>>({});
// Notify parent when count changes
useEffect(() => {
const count = suppliers.length;
onUpdate?.({
itemsCount: count,
canContinue: count >= 1,
});
}, [suppliers.length, onUpdate]);
// Validation
const validateForm = (): boolean => {
const newErrors: Record<string, string> = {};
if (!formData.name.trim()) {
newErrors.name = t('setup_wizard:suppliers.errors.name_required', 'Name is required');
}
if (formData.email && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(formData.email)) {
newErrors.email = t('setup_wizard:suppliers.errors.email_invalid', 'Invalid email format');
}
setErrors(newErrors);
return Object.keys(newErrors).length === 0;
};
// Form handlers
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!validateForm()) return;
try {
if (editingId) {
// Update existing supplier
await updateSupplierMutation.mutateAsync({
tenantId,
supplierId: editingId,
updateData: {
name: formData.name,
supplier_type: formData.supplier_type,
contact_person: formData.contact_person || null,
phone: formData.phone || null,
email: formData.email || null,
} as SupplierUpdate,
});
setEditingId(null);
} else {
// Create new supplier
await createSupplierMutation.mutateAsync({
tenantId,
supplierData: {
name: formData.name,
supplier_type: formData.supplier_type,
contact_person: formData.contact_person || undefined,
phone: formData.phone || undefined,
email: formData.email || undefined,
} as SupplierCreate,
});
}
// Reset form
resetForm();
} catch (error) {
console.error('Error saving supplier:', error);
}
};
const resetForm = () => {
setFormData({
name: '',
supplier_type: 'ingredients',
contact_person: '',
phone: '',
email: '',
});
setErrors({});
setIsAdding(false);
setEditingId(null);
};
const handleEdit = (supplier: any) => {
setFormData({
name: supplier.name,
supplier_type: supplier.supplier_type,
contact_person: supplier.contact_person || '',
phone: supplier.phone || '',
email: supplier.email || '',
});
setEditingId(supplier.id);
setIsAdding(true);
};
const handleDelete = async (supplierId: string) => {
if (!window.confirm(t('setup_wizard:suppliers.confirm_delete', 'Are you sure you want to delete this supplier?'))) {
return;
}
try {
await deleteSupplierMutation.mutateAsync({ tenantId, supplierId });
} catch (error) {
console.error('Error deleting supplier:', error);
}
};
const supplierTypeOptions = [
{ value: 'ingredients', label: t('suppliers:type.ingredients', 'Ingredients') },
{ value: 'packaging', label: t('suppliers:type.packaging', 'Packaging') },
{ value: 'equipment', label: t('suppliers:type.equipment', 'Equipment') },
{ value: 'utilities', label: t('suppliers:type.utilities', 'Utilities') },
{ value: 'services', label: t('suppliers:type.services', 'Services') },
{ value: 'other', label: t('suppliers:type.other', 'Other') },
];
return ( return (
<div className="space-y-6"> <div className="space-y-6">
{/* Why This Matters */} {/* Why This Matters */}
@@ -20,23 +164,260 @@ export const SuppliersSetupStep: React.FC<SetupStepProps> = () => {
</p> </p>
</div> </div>
{/* Placeholder content - will be implemented in Phase 2 */} {/* Progress indicator */}
<div className="text-center py-12 border-2 border-dashed border-[var(--border-secondary)] rounded-lg"> <div className="flex items-center justify-between p-3 bg-[var(--bg-secondary)] rounded-lg">
<div className="w-16 h-16 bg-[var(--bg-secondary)] rounded-full mx-auto mb-4 flex items-center justify-center"> <div className="flex items-center gap-2">
<svg className="w-8 h-8 text-[var(--text-tertiary)]" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg className="w-5 h-5 text-[var(--text-secondary)]" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4" /> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4" />
</svg> </svg>
<span className="text-sm font-medium text-[var(--text-primary)]">
{t('setup_wizard:suppliers.added_count', { count: suppliers.length, defaultValue: '{{count}} supplier added' })}
</span>
</div> </div>
<h3 className="text-lg font-semibold text-[var(--text-primary)] mb-2"> {suppliers.length >= 1 && (
{t('setup_wizard:suppliers.placeholder_title', 'Suppliers Management')} <div className="flex items-center gap-1 text-xs text-[var(--color-success)]">
</h3> <svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<p className="text-[var(--text-secondary)] mb-4"> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
{t('setup_wizard:suppliers.placeholder_desc', 'This feature will be implemented in Phase 2')} </svg>
</p> {t('setup_wizard:suppliers.minimum_met', 'Minimum requirement met')}
<p className="text-sm text-[var(--text-tertiary)]"> </div>
{t('setup_wizard:suppliers.min_required', 'Minimum required: 1 supplier')} )}
</p>
</div> </div>
{/* Suppliers list */}
{suppliers.length > 0 && (
<div className="space-y-2">
<h4 className="text-sm font-medium text-[var(--text-secondary)]">
{t('setup_wizard:suppliers.your_suppliers', 'Your Suppliers')}
</h4>
<div className="space-y-2">
{suppliers.map((supplier) => (
<div
key={supplier.id}
className="flex items-center justify-between p-3 bg-[var(--bg-secondary)] border border-[var(--border-secondary)] rounded-lg hover:border-[var(--border-primary)] transition-colors"
>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<h5 className="font-medium text-[var(--text-primary)] truncate">{supplier.name}</h5>
<span className="text-xs px-2 py-0.5 bg-[var(--bg-primary)] rounded-full text-[var(--text-secondary)]">
{supplierTypeOptions.find(opt => opt.value === supplier.supplier_type)?.label || supplier.supplier_type}
</span>
</div>
<div className="flex items-center gap-3 mt-1 text-xs text-[var(--text-secondary)]">
{supplier.contact_person && (
<span className="flex items-center gap-1">
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" />
</svg>
{supplier.contact_person}
</span>
)}
{supplier.phone && (
<span className="flex items-center gap-1">
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 5a2 2 0 012-2h3.28a1 1 0 01.948.684l1.498 4.493a1 1 0 01-.502 1.21l-2.257 1.13a11.042 11.042 0 005.516 5.516l1.13-2.257a1 1 0 011.21-.502l4.493 1.498a1 1 0 01.684.949V19a2 2 0 01-2 2h-1C9.716 21 3 14.284 3 6V5z" />
</svg>
{supplier.phone}
</span>
)}
{supplier.email && (
<span className="flex items-center gap-1 truncate">
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
</svg>
{supplier.email}
</span>
)}
</div>
</div>
<div className="flex items-center gap-2 ml-4">
<button
type="button"
onClick={() => handleEdit(supplier)}
className="p-1.5 text-[var(--text-secondary)] hover:text-[var(--color-primary)] hover:bg-[var(--bg-primary)] rounded transition-colors"
aria-label={t('common:edit', 'Edit')}
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
</svg>
</button>
<button
type="button"
onClick={() => handleDelete(supplier.id)}
className="p-1.5 text-[var(--text-secondary)] hover:text-[var(--color-error)] hover:bg-[var(--color-error)]/10 rounded transition-colors"
aria-label={t('common:delete', 'Delete')}
disabled={deleteSupplierMutation.isPending}
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
</button>
</div>
</div>
))}
</div>
</div>
)}
{/* Add/Edit form */}
{isAdding ? (
<form onSubmit={handleSubmit} className="space-y-4 p-4 border-2 border-[var(--color-primary)] rounded-lg bg-[var(--bg-secondary)]">
<div className="flex items-center justify-between mb-2">
<h4 className="font-medium text-[var(--text-primary)]">
{editingId ? t('setup_wizard:suppliers.edit_supplier', 'Edit Supplier') : t('setup_wizard:suppliers.add_supplier', 'Add Supplier')}
</h4>
<button
type="button"
onClick={resetForm}
className="text-sm text-[var(--text-secondary)] hover:text-[var(--text-primary)]"
>
{t('common:cancel', 'Cancel')}
</button>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{/* Name */}
<div className="md:col-span-2">
<label htmlFor="supplier-name" className="block text-sm font-medium text-[var(--text-primary)] mb-1">
{t('setup_wizard:suppliers.fields.name', 'Supplier Name')} <span className="text-[var(--color-error)]">*</span>
</label>
<input
id="supplier-name"
type="text"
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
className={`w-full px-3 py-2 bg-[var(--bg-primary)] border ${errors.name ? 'border-[var(--color-error)]' : 'border-[var(--border-secondary)]'} rounded-lg focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)] text-[var(--text-primary)]`}
placeholder={t('setup_wizard:suppliers.placeholders.name', 'e.g., Molinos SA, Distribuidora López')}
/>
{errors.name && <p className="mt-1 text-xs text-[var(--color-error)]">{errors.name}</p>}
</div>
{/* Supplier Type */}
<div>
<label htmlFor="supplier-type" className="block text-sm font-medium text-[var(--text-primary)] mb-1">
{t('setup_wizard:suppliers.fields.type', 'Type')} <span className="text-[var(--color-error)]">*</span>
</label>
<select
id="supplier-type"
value={formData.supplier_type}
onChange={(e) => setFormData({ ...formData, supplier_type: e.target.value as SupplierType })}
className="w-full px-3 py-2 bg-[var(--bg-primary)] border border-[var(--border-secondary)] rounded-lg focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)] text-[var(--text-primary)]"
>
{supplierTypeOptions.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</div>
{/* Contact Person */}
<div>
<label htmlFor="contact-person" className="block text-sm font-medium text-[var(--text-primary)] mb-1">
{t('setup_wizard:suppliers.fields.contact_person', 'Contact Person')}
</label>
<input
id="contact-person"
type="text"
value={formData.contact_person}
onChange={(e) => setFormData({ ...formData, contact_person: e.target.value })}
className="w-full px-3 py-2 bg-[var(--bg-primary)] border border-[var(--border-secondary)] rounded-lg focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)] text-[var(--text-primary)]"
placeholder={t('setup_wizard:suppliers.placeholders.contact_person', 'e.g., Juan Pérez')}
/>
</div>
{/* Phone */}
<div>
<label htmlFor="phone" className="block text-sm font-medium text-[var(--text-primary)] mb-1">
{t('setup_wizard:suppliers.fields.phone', 'Phone')}
</label>
<input
id="phone"
type="tel"
value={formData.phone}
onChange={(e) => setFormData({ ...formData, phone: e.target.value })}
className="w-full px-3 py-2 bg-[var(--bg-primary)] border border-[var(--border-secondary)] rounded-lg focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)] text-[var(--text-primary)]"
placeholder={t('setup_wizard:suppliers.placeholders.phone', 'e.g., +54 11 1234-5678')}
/>
</div>
{/* Email */}
<div>
<label htmlFor="email" className="block text-sm font-medium text-[var(--text-primary)] mb-1">
{t('setup_wizard:suppliers.fields.email', 'Email')}
</label>
<input
id="email"
type="email"
value={formData.email}
onChange={(e) => setFormData({ ...formData, email: e.target.value })}
className={`w-full px-3 py-2 bg-[var(--bg-primary)] border ${errors.email ? 'border-[var(--color-error)]' : 'border-[var(--border-secondary)]'} rounded-lg focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)] text-[var(--text-primary)]`}
placeholder={t('setup_wizard:suppliers.placeholders.email', 'e.g., ventas@proveedor.com')}
/>
{errors.email && <p className="mt-1 text-xs text-[var(--color-error)]">{errors.email}</p>}
</div>
</div>
<div className="flex gap-2 pt-2">
<button
type="submit"
disabled={createSupplierMutation.isPending || updateSupplierMutation.isPending}
className="px-4 py-2 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"
>
{(createSupplierMutation.isPending || updateSupplierMutation.isPending) ? (
<span className="flex items-center gap-2">
<svg className="animate-spin h-4 w-4" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
</svg>
{t('common:saving', 'Saving...')}
</span>
) : editingId ? (
t('common:update', 'Update')
) : (
t('common:add', 'Add')
)}
</button>
<button
type="button"
onClick={resetForm}
className="px-4 py-2 text-[var(--text-secondary)] hover:text-[var(--text-primary)] hover:bg-[var(--bg-primary)] rounded-lg transition-colors"
>
{t('common:cancel', 'Cancel')}
</button>
</div>
</form>
) : (
<button
type="button"
onClick={() => setIsAdding(true)}
className="w-full p-4 border-2 border-dashed border-[var(--border-secondary)] rounded-lg hover:border-[var(--color-primary)] hover:bg-[var(--bg-secondary)] transition-colors group"
>
<div className="flex items-center justify-center gap-2 text-[var(--text-secondary)] group-hover:text-[var(--color-primary)]">
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 6v6m0 0v6m0-6h6m-6 0H6" />
</svg>
<span className="font-medium">
{suppliers.length === 0
? t('setup_wizard:suppliers.add_first', 'Add Your First Supplier')
: t('setup_wizard:suppliers.add_another', 'Add Another Supplier')}
</span>
</div>
</button>
)}
{/* Loading state */}
{isLoading && suppliers.length === 0 && (
<div className="text-center py-8">
<svg className="animate-spin h-8 w-8 text-[var(--color-primary)] mx-auto" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
</svg>
<p className="mt-2 text-sm text-[var(--text-secondary)]">
{t('common:loading', 'Loading...')}
</p>
</div>
)}
</div> </div>
); );
}; };