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:
@@ -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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user