Improve unified wizard

This commit is contained in:
Urtzi Alfaro
2025-12-30 20:51:03 +01:00
parent c07df124fb
commit 0dc5f76938
13 changed files with 613 additions and 452 deletions

View File

@@ -6,12 +6,18 @@ import { AnyWizardData } from './types';
import { useTenant } from '../../../stores/tenant.store';
import { useCreatePurchaseOrder } from '../../../api/hooks/purchase-orders';
import { useCreateProductionBatch } from '../../../api/hooks/production';
import { useCreateIngredient, useAddStock } from '../../../api/hooks/inventory';
import { useCreateSupplier } from '../../../api/hooks/suppliers';
import { toast } from 'react-hot-toast';
import type { ProductionBatchCreate } from '../../../api/types/production';
import type { IngredientCreate, StockCreate } from '../../../api/types/inventory';
import type { SupplierCreate } from '../../../api/types/suppliers';
import { ProductType } from '../../../api/types/inventory';
import { SupplierType, PaymentTerms } from '../../../api/types/suppliers';
import { ProductionPriorityEnum } from '../../../api/types/production';
// Import specific wizards
import { InventoryWizardSteps, ProductTypeStep, BasicInfoStep, StockConfigStep } from './wizards/InventoryWizard';
import { InventoryWizardSteps } from './wizards/InventoryWizard';
import { SupplierWizardSteps } from './wizards/SupplierWizard';
import { RecipeWizardSteps } from './wizards/RecipeWizard';
import { EquipmentWizardSteps } from './wizards/EquipmentWizard';
@@ -23,11 +29,18 @@ import { SalesEntryWizardSteps } from './wizards/SalesEntryWizard';
import { PurchaseOrderWizardSteps } from './wizards/PurchaseOrderWizard';
import { ProductionBatchWizardSteps } from './wizards/ProductionBatchWizard';
// Services for wizards that handle their own submission
import { equipmentService } from '../../../api/services/equipment';
import { qualityTemplateService } from '../../../api/services/qualityTemplates';
import OrdersService from '../../../api/services/orders';
import { salesService } from '../../../api/services/sales';
import { recipesService } from '../../../api/services/recipes';
import { authService } from '../../../api/services/auth';
interface UnifiedAddWizardProps {
isOpen: boolean;
onClose: () => void;
onComplete?: (itemType: ItemType, data?: any) => void;
// Optional: Start with a specific item type (when opened from individual page buttons)
initialItemType?: ItemType;
}
@@ -43,23 +56,21 @@ export const UnifiedAddWizard: React.FC<UnifiedAddWizardProps> = ({
const [wizardData, setWizardData] = useState<AnyWizardData>({});
const [isSubmitting, setIsSubmitting] = useState(false);
// Get current tenant
const { currentTenant } = useTenant();
// API hooks
// API hooks for wizards with proper hook support
const createPurchaseOrderMutation = useCreatePurchaseOrder();
const createProductionBatchMutation = useCreateProductionBatch();
const createIngredientMutation = useCreateIngredient();
const addStockMutation = useAddStock();
const createSupplierMutation = useCreateSupplier();
// Use a ref to store the current data - this allows step components
// to always access the latest data without causing the steps array to be recreated
const dataRef = useRef<AnyWizardData>({});
// Update ref whenever data changes
useEffect(() => {
dataRef.current = wizardData;
}, [wizardData]);
// Reset state when modal closes
const handleClose = useCallback(() => {
setSelectedItemType(initialItemType || null);
setWizardData({});
@@ -68,22 +79,17 @@ export const UnifiedAddWizard: React.FC<UnifiedAddWizardProps> = ({
onClose();
}, [onClose, initialItemType]);
// Handle item type selection from step 0
const handleItemTypeSelect = useCallback((itemType: ItemType) => {
setSelectedItemType(itemType);
}, []);
// CRITICAL FIX: Update both ref AND state, but wizardSteps won't recreate
// The step component needs to re-render to show typed text (controlled inputs)
// But wizardSteps useMemo ensures steps array doesn't recreate, so no component recreation
const handleDataChange = useCallback((newData: AnyWizardData) => {
// Update ref first for immediate access
dataRef.current = newData;
// Update state to trigger re-render (controlled inputs need this)
setWizardData(newData);
}, []);
// Handle wizard completion with API submission
// Centralized wizard completion handler
// All API submissions are handled here for consistency
const handleWizardComplete = useCallback(
async () => {
if (!selectedItemType || !currentTenant?.id) {
@@ -93,36 +99,29 @@ export const UnifiedAddWizard: React.FC<UnifiedAddWizardProps> = ({
setIsSubmitting(true);
try {
const finalData = dataRef.current as any; // Cast to any for flexible data access
const finalData = dataRef.current as any;
// Handle Purchase Order submission
// ========================================
// PURCHASE ORDER
// ========================================
if (selectedItemType === 'purchase-order') {
// Validate items have positive quantities and prices
if ((finalData.items || []).some((item: any) =>
Number(item.ordered_quantity) < 0.01 || Number(item.unit_price) < 0.01
)) {
throw new Error('Todos los productos deben tener cantidad y precio mayor a 0');
}
const subtotal = (finalData.items || []).reduce(
(sum: number, item: any) => sum + (Number(item.ordered_quantity) * Number(item.unit_price)),
0
);
// Convert date string to ISO datetime with timezone (start of day in local timezone)
const deliveryDate = new Date(finalData.required_delivery_date + 'T00:00:00');
if (isNaN(deliveryDate.getTime())) {
throw new Error('Fecha de entrega inválida');
}
const requiredDeliveryDateTime = deliveryDate.toISOString();
await createPurchaseOrderMutation.mutateAsync({
tenantId: currentTenant.id,
data: {
supplier_id: finalData.supplier_id,
required_delivery_date: requiredDeliveryDateTime,
required_delivery_date: deliveryDate.toISOString(),
priority: finalData.priority || 'normal',
subtotal: subtotal,
tax_amount: Number(finalData.tax_amount) || 0,
shipping_cost: Number(finalData.shipping_cost) || 0,
discount_amount: Number(finalData.discount_amount) || 0,
@@ -138,9 +137,10 @@ export const UnifiedAddWizard: React.FC<UnifiedAddWizardProps> = ({
toast.success('Orden de compra creada exitosamente');
}
// Handle Production Batch submission
// ========================================
// PRODUCTION BATCH
// ========================================
if (selectedItemType === 'production-batch') {
// Validate quantities
if (Number(finalData.planned_quantity) < 0.01) {
throw new Error('La cantidad planificada debe ser mayor a 0');
}
@@ -148,12 +148,10 @@ export const UnifiedAddWizard: React.FC<UnifiedAddWizardProps> = ({
throw new Error('La duración planificada debe ser mayor a 0');
}
// Convert staff_assigned from string to array
const staffArray = finalData.staff_assigned_string
? finalData.staff_assigned_string.split(',').map((s: string) => s.trim()).filter((s: string) => s.length > 0)
: [];
// Convert datetime-local strings to ISO datetime with timezone
const plannedStartDate = new Date(finalData.planned_start_time);
const plannedEndDate = new Date(finalData.planned_end_time);
@@ -173,7 +171,7 @@ export const UnifiedAddWizard: React.FC<UnifiedAddWizardProps> = ({
planned_end_time: plannedEndDate.toISOString(),
planned_quantity: Number(finalData.planned_quantity),
planned_duration_minutes: Number(finalData.planned_duration_minutes),
priority: (finalData.priority || ProductionPriorityEnum.MEDIUM) as ProductionPriorityEnum,
priority: finalData.priority || ProductionPriorityEnum.MEDIUM,
is_rush_order: finalData.is_rush_order || false,
is_special_recipe: finalData.is_special_recipe || false,
production_notes: finalData.production_notes || undefined,
@@ -192,10 +190,426 @@ export const UnifiedAddWizard: React.FC<UnifiedAddWizardProps> = ({
toast.success('Lote de producción creado exitosamente');
}
// ========================================
// INVENTORY (INGREDIENT)
// ========================================
if (selectedItemType === 'inventory') {
if (!finalData.name || finalData.name.trim().length < 2) {
throw new Error('El nombre del producto debe tener al menos 2 caracteres');
}
const category = finalData.productType === 'ingredient'
? finalData.ingredientCategory
: finalData.productCategory;
if (!category) {
throw new Error('Debes seleccionar una categoría');
}
// Convert allergenInfo string to array format expected by backend
let allergenInfoArray: string[] | undefined = undefined;
if (finalData.allergenInfo && typeof finalData.allergenInfo === 'string') {
// Parse comma-separated allergens into array
allergenInfoArray = finalData.allergenInfo
.split(',')
.map((a: string) => a.trim().toLowerCase())
.filter((a: string) => a.length > 0);
} else if (Array.isArray(finalData.allergenInfo)) {
allergenInfoArray = finalData.allergenInfo;
}
const ingredientData: IngredientCreate = {
name: finalData.name.trim(),
product_type: finalData.productType === 'finished_product'
? ProductType.FINISHED_PRODUCT
: ProductType.INGREDIENT,
sku: finalData.sku || undefined,
barcode: finalData.barcode || undefined,
category: category,
description: finalData.description || undefined,
brand: finalData.brand || undefined,
unit_of_measure: finalData.unitOfMeasure || 'units',
package_size: finalData.packageSize ? Number(finalData.packageSize) : undefined,
shelf_life_days: finalData.shelfLifeDays ? Number(finalData.shelfLifeDays) : undefined,
is_perishable: finalData.isPerishable ?? true,
allergen_info: allergenInfoArray && allergenInfoArray.length > 0
? { allergens: allergenInfoArray }
: undefined,
};
const createdIngredient = await createIngredientMutation.mutateAsync({
tenantId: currentTenant.id,
ingredientData,
});
const initialLots = finalData.initialLots || [];
if (initialLots.length > 0 && createdIngredient?.id) {
for (const lot of initialLots) {
if (lot.quantity && Number(lot.quantity) > 0) {
const stockData: StockCreate = {
ingredient_id: createdIngredient.id,
current_quantity: Number(lot.quantity),
unit_cost: lot.unitCost ? Number(lot.unitCost) : undefined,
lot_number: lot.lotNumber || undefined,
expiration_date: lot.expirationDate || undefined,
storage_location: lot.location || undefined,
};
await addStockMutation.mutateAsync({
tenantId: currentTenant.id,
stockData,
});
}
}
}
toast.success('Producto de inventario creado exitosamente');
}
// ========================================
// SUPPLIER
// ========================================
if (selectedItemType === 'supplier') {
if (!finalData.name || finalData.name.trim().length < 1) {
throw new Error('El nombre del proveedor es requerido');
}
if (!finalData.supplierType) {
throw new Error('El tipo de proveedor es requerido');
}
// Convert comma-separated certifications string to array
let certificationsArray: string[] | undefined = undefined;
if (finalData.certifications && typeof finalData.certifications === 'string') {
certificationsArray = finalData.certifications
.split(',')
.map((c: string) => c.trim())
.filter((c: string) => c.length > 0);
} else if (Array.isArray(finalData.certifications)) {
certificationsArray = finalData.certifications;
}
// Convert comma-separated specializations string to array
let specializationsArray: string[] | undefined = undefined;
if (finalData.specializations && typeof finalData.specializations === 'string') {
specializationsArray = finalData.specializations
.split(',')
.map((s: string) => s.trim())
.filter((s: string) => s.length > 0);
} else if (Array.isArray(finalData.specializations)) {
specializationsArray = finalData.specializations;
}
const supplierData: SupplierCreate = {
name: finalData.name.trim(),
supplier_type: finalData.supplierType as SupplierType,
supplier_code: finalData.supplierCode || undefined,
tax_id: finalData.taxId || undefined,
registration_number: finalData.registrationNumber || undefined,
contact_person: finalData.contactPerson || undefined,
email: finalData.email || undefined,
phone: finalData.phone || undefined,
mobile: finalData.mobile || undefined,
website: finalData.website || undefined,
address_line1: finalData.addressLine1 || undefined,
address_line2: finalData.addressLine2 || undefined,
city: finalData.city || undefined,
state_province: finalData.stateProvince || undefined,
postal_code: finalData.postalCode || undefined,
country: finalData.country || undefined,
payment_terms: (finalData.paymentTerms as PaymentTerms) || PaymentTerms.NET_30,
credit_limit: finalData.creditLimit ? Number(finalData.creditLimit) : undefined,
currency: finalData.currency || 'EUR',
standard_lead_time: finalData.standardLeadTime ? Number(finalData.standardLeadTime) : 3,
minimum_order_amount: finalData.minimumOrderAmount ? Number(finalData.minimumOrderAmount) : undefined,
delivery_area: finalData.deliveryArea || undefined,
notes: finalData.notes || undefined,
certifications: certificationsArray && certificationsArray.length > 0
? certificationsArray
: undefined,
specializations: specializationsArray && specializationsArray.length > 0
? specializationsArray
: undefined,
};
await createSupplierMutation.mutateAsync({
tenantId: currentTenant.id,
supplierData,
});
toast.success('Proveedor creado exitosamente');
}
// ========================================
// EQUIPMENT (using service directly)
// ========================================
if (selectedItemType === 'equipment') {
if (!finalData.name || finalData.name.trim().length < 2) {
throw new Error('El nombre del equipo es requerido');
}
if (!finalData.type) {
throw new Error('El tipo de equipo es requerido');
}
const equipmentData = {
name: finalData.name.trim(),
type: finalData.type,
model: finalData.model || undefined,
location: finalData.location || undefined,
status: 'operational',
install_date: finalData.installDate || new Date().toISOString().split('T')[0],
last_maintenance_date: new Date().toISOString().split('T')[0],
next_maintenance_date: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString().split('T')[0],
maintenance_interval_days: 30,
is_active: true,
};
await equipmentService.createEquipment(currentTenant.id, equipmentData as any);
toast.success('Equipo creado exitosamente');
}
// ========================================
// QUALITY TEMPLATE (using service directly)
// ========================================
if (selectedItemType === 'quality-template') {
if (!finalData.name || finalData.name.trim().length < 1) {
throw new Error('El nombre de la plantilla es requerido');
}
if (!finalData.checkType) {
throw new Error('El tipo de control es requerido');
}
const templateData = {
name: finalData.name.trim(),
check_type: finalData.checkType,
template_code: finalData.templateCode || undefined,
category: finalData.category || undefined,
description: finalData.description || undefined,
weight: finalData.weight ? Number(finalData.weight) : 1,
applicable_stages: finalData.applicableStages || [],
min_value: finalData.minValue ? Number(finalData.minValue) : undefined,
max_value: finalData.maxValue ? Number(finalData.maxValue) : undefined,
target_value: finalData.targetValue ? Number(finalData.targetValue) : undefined,
unit: finalData.unit || undefined,
tolerance_percentage: finalData.tolerancePercentage ? Number(finalData.tolerancePercentage) : undefined,
instructions: finalData.instructions || undefined,
is_required: finalData.isRequired ?? true,
is_active: finalData.isActive ?? true,
requires_photo: finalData.requiresPhoto ?? false,
is_critical: finalData.isCritical ?? false,
created_by: currentTenant.id,
};
await qualityTemplateService.createTemplate(currentTenant.id, templateData as any);
toast.success('Plantilla de calidad creada exitosamente');
}
// ========================================
// CUSTOMER (using service directly)
// ========================================
if (selectedItemType === 'customer') {
if (!finalData.name || finalData.name.trim().length < 1) {
throw new Error('El nombre del cliente es requerido');
}
const customerPayload = {
name: finalData.name.trim(),
customer_code: finalData.customerCode || `CUST-${Date.now().toString().slice(-6)}`,
customer_type: finalData.customerType || 'individual',
country: finalData.country || 'ES',
business_name: finalData.businessName || undefined,
email: finalData.email || undefined,
phone: finalData.phone || undefined,
address_line1: finalData.addressLine1 || undefined,
address_line2: finalData.addressLine2 || undefined,
city: finalData.city || undefined,
state: finalData.state || undefined,
postal_code: finalData.postalCode || undefined,
tax_id: finalData.taxId || undefined,
payment_terms: finalData.paymentTerms || 'immediate',
credit_limit: finalData.creditLimit ? parseFloat(finalData.creditLimit) : undefined,
discount_percentage: finalData.discountPercentage ? Number(finalData.discountPercentage) : 0,
customer_segment: finalData.customerSegment || 'regular',
priority_level: finalData.priorityLevel || 'normal',
preferred_delivery_method: finalData.preferredDeliveryMethod || 'delivery',
special_instructions: finalData.specialInstructions || undefined,
is_active: true,
};
await OrdersService.createCustomer({ tenant_id: currentTenant.id, ...customerPayload } as any);
toast.success('Cliente creado exitosamente');
}
// ========================================
// CUSTOMER ORDER (using service directly)
// ========================================
if (selectedItemType === 'customer-order') {
if (!finalData.customer && !finalData.newCustomerName) {
throw new Error('Debes seleccionar o crear un cliente');
}
if (!finalData.orderItems || finalData.orderItems.length === 0) {
throw new Error('Debes agregar al menos un producto al pedido');
}
let customerId = finalData.customer?.id;
if (finalData.showNewCustomerForm && finalData.newCustomerName) {
const newCustomerPayload = {
name: finalData.newCustomerName,
customer_code: `CUST-${Date.now().toString().slice(-6)}`,
customer_type: finalData.newCustomerType || 'individual',
country: 'ES',
phone: finalData.newCustomerPhone || undefined,
email: finalData.newCustomerEmail || undefined,
is_active: true,
discount_percentage: 0,
customer_segment: 'regular',
priority_level: 'normal',
preferred_delivery_method: 'delivery',
payment_terms: 'immediate',
};
const newCustomer = await OrdersService.createCustomer({ tenant_id: currentTenant.id, ...newCustomerPayload } as any);
customerId = newCustomer.id;
}
if (!customerId) {
throw new Error('No se pudo obtener el ID del cliente');
}
const orderPayload = {
tenant_id: currentTenant.id,
customer_id: customerId,
order_type: finalData.orderType || 'standard',
priority: finalData.priority || 'normal',
requested_delivery_date: finalData.requestedDeliveryDate || new Date().toISOString(),
delivery_method: finalData.deliveryMethod || 'pickup',
delivery_address: finalData.deliveryAddress || undefined,
payment_method: finalData.paymentMethod || undefined,
payment_terms: finalData.paymentTerms || 'immediate',
discount_percentage: finalData.discountPercentage ? Number(finalData.discountPercentage) : 0,
delivery_fee: finalData.deliveryFee ? Number(finalData.deliveryFee) : 0,
special_instructions: finalData.specialInstructions || undefined,
order_source: 'manual',
sales_channel: 'direct',
items: (finalData.orderItems || []).map((item: any) => ({
product_id: item.product_id || item.id,
product_name: item.product_name || item.name,
quantity: Number(item.quantity),
unit_of_measure: item.unit_of_measure || 'units',
unit_price: Number(item.unit_price || item.price),
line_discount: 0,
})),
};
await OrdersService.createOrder(orderPayload as any);
toast.success('Pedido creado exitosamente');
}
// ========================================
// TEAM MEMBER (using auth service)
// ========================================
if (selectedItemType === 'team-member') {
if (!finalData.fullName || finalData.fullName.trim().length < 1) {
throw new Error('El nombre completo es requerido');
}
if (!finalData.email || !finalData.email.includes('@')) {
throw new Error('El email es requerido y debe ser válido');
}
if (!finalData.role) {
throw new Error('El rol es requerido');
}
const tempPassword = `Temp${Math.random().toString(36).substring(2, 10)}!`;
const registrationData = {
email: finalData.email.trim(),
password: tempPassword,
full_name: finalData.fullName.trim(),
phone_number: finalData.phone || undefined,
tenant_id: currentTenant.id,
role: finalData.role,
};
await authService.register(registrationData);
toast.success('Miembro del equipo agregado exitosamente');
}
// ========================================
// SALES ENTRY (using service directly)
// ========================================
if (selectedItemType === 'sales-entry') {
if (finalData.entryMethod === 'manual') {
if (!finalData.salesItems || finalData.salesItems.length === 0) {
throw new Error('Debes agregar al menos una venta');
}
for (const item of finalData.salesItems) {
const salesData = {
inventory_product_id: item.productId || item.product_id,
quantity_sold: Number(item.quantity),
unit_price: Number(item.unitPrice || item.unit_price),
revenue: Number(item.quantity) * Number(item.unitPrice || item.unit_price),
date: finalData.saleDate || new Date().toISOString(),
sales_channel: 'in_store',
source: 'manual',
notes: finalData.notes || undefined,
};
await salesService.createSalesRecord(currentTenant.id, salesData as any);
}
toast.success('Ventas registradas exitosamente');
} else if (finalData.entryMethod === 'file' || finalData.entryMethod === 'upload') {
toast.success('Datos de ventas importados exitosamente');
}
}
// ========================================
// RECIPE (using service directly)
// ========================================
if (selectedItemType === 'recipe') {
if (!finalData.name || finalData.name.trim().length < 1) {
throw new Error('El nombre de la receta es requerido');
}
if (!finalData.finishedProductId) {
throw new Error('Debes seleccionar un producto terminado');
}
if (!finalData.ingredients || finalData.ingredients.length === 0) {
throw new Error('Debes agregar al menos un ingrediente');
}
const recipeIngredients = finalData.ingredients.map((ing: any, index: number) => ({
ingredient_id: ing.ingredientId || ing.ingredient_id || ing.id,
quantity: Number(ing.quantity),
unit: ing.unit,
ingredient_notes: ing.notes || null,
preparation_method: ing.preparationMethod || ing.preparation_method || null,
is_optional: ing.isOptional || ing.is_optional || false,
ingredient_order: index + 1,
}));
const recipeData = {
name: finalData.name.trim(),
category: finalData.category || 'general',
finished_product_id: finalData.finishedProductId,
yield_quantity: parseFloat(finalData.yieldQuantity) || 1,
yield_unit: finalData.yieldUnit || 'units',
version: finalData.version || '1.0',
difficulty_level: finalData.difficultyLevel ? Number(finalData.difficultyLevel) : 3,
prep_time_minutes: finalData.prepTime ? parseInt(finalData.prepTime) : null,
cook_time_minutes: finalData.cookTime ? parseInt(finalData.cookTime) : null,
rest_time_minutes: finalData.restTime ? parseInt(finalData.restTime) : null,
total_time_minutes: finalData.totalTime ? parseInt(finalData.totalTime) : null,
recipe_code: finalData.recipeCode || null,
description: finalData.description || null,
preparation_notes: finalData.preparationNotes || null,
storage_instructions: finalData.storageInstructions || null,
instructions: finalData.instructions || null,
ingredients: recipeIngredients,
quality_check_configuration: finalData.qualityConfiguration || undefined,
};
await recipesService.createRecipe(currentTenant.id, recipeData as any);
toast.success('Receta creada exitosamente');
}
// Call the parent's onComplete callback
onComplete?.(selectedItemType, finalData);
// Close the modal
handleClose();
} catch (error: any) {
console.error('Error submitting wizard data:', error);
@@ -209,19 +623,16 @@ export const UnifiedAddWizard: React.FC<UnifiedAddWizardProps> = ({
currentTenant,
createPurchaseOrderMutation,
createProductionBatchMutation,
createIngredientMutation,
addStockMutation,
createSupplierMutation,
onComplete,
handleClose,
]
);
// Get wizard steps based on selected item type
// ARCHITECTURAL SOLUTION: We pass dataRef and setWizardData to wizard step functions.
// The wizard steps use these in their component wrappers, which creates a closure
// that always accesses the CURRENT data from dataRef.current, without needing
// to recreate the steps array on every data change.
const wizardSteps = useMemo((): WizardStep[] => {
if (!selectedItemType) {
// Step 0: Item Type Selection
return [
{
id: 'item-type-selection',
@@ -234,8 +645,6 @@ export const UnifiedAddWizard: React.FC<UnifiedAddWizardProps> = ({
];
}
// Pass dataRef and setWizardData - the wizard step functions will use
// dataRef.current to always access fresh data without recreating steps
switch (selectedItemType) {
case 'inventory':
return InventoryWizardSteps(dataRef, setWizardData);
@@ -262,9 +671,8 @@ export const UnifiedAddWizard: React.FC<UnifiedAddWizardProps> = ({
default:
return [];
}
}, [selectedItemType, handleItemTypeSelect, wizardData.entryMethod]); // Add entryMethod for dynamic sales-entry steps
}, [selectedItemType, handleItemTypeSelect, (wizardData as any).entryMethod]);
// Get wizard title based on selected item type
const getWizardTitle = (): string => {
if (!selectedItemType) {
return 'Agregar Contenido';

View File

@@ -350,64 +350,21 @@ export const CustomerWizardSteps = (
dataRef: React.MutableRefObject<Record<string, any>>,
setData: (data: Record<string, any>) => void
): WizardStep[] => {
// New architecture: return direct component references instead of arrow functions
// dataRef and onDataChange are now passed through WizardModal props
// Wizard steps only handle UI and validation
// API submission is handled centrally in UnifiedAddWizard.handleWizardComplete
return [
{
id: 'customer-details',
title: 'wizards:customer.steps.customerDetails',
description: 'wizards:customer.steps.customerDetailsDescription',
component: CustomerDetailsStep,
validate: async () => {
// Import these at the top level of this file would be better, but for now do it inline
const { useTenant } = await import('../../../../stores/tenant.store');
const OrdersService = (await import('../../../../api/services/orders')).default;
const { showToast } = await import('../../../../utils/toast');
const i18next = (await import('i18next')).default;
validate: () => {
const data = dataRef.current;
const { currentTenant } = useTenant.getState();
if (!currentTenant?.id) {
showToast.error(i18next.t('wizards:customer.messages.errorObtainingTenantInfo'));
// Basic validation - name is required
if (!data.name || data.name.trim().length < 1) {
return false;
}
try {
const payload = {
name: data.name || '',
customer_code: data.customerCode || '',
customer_type: data.customerType || 'individual',
country: data.country || 'US',
business_name: data.businessName || undefined,
email: data.email || undefined,
phone: data.phone || undefined,
address_line1: data.addressLine1 || undefined,
address_line2: data.addressLine2 || undefined,
city: data.city || undefined,
state: data.state || undefined,
postal_code: data.postalCode || undefined,
tax_id: data.taxId || undefined,
business_license: data.businessLicense || undefined,
payment_terms: data.paymentTerms || 'immediate',
credit_limit: data.creditLimit ? parseFloat(data.creditLimit) : undefined,
discount_percentage: data.discountPercentage || 0,
customer_segment: data.customerSegment || 'regular',
priority_level: data.priorityLevel || 'normal',
preferred_delivery_method: data.preferredDeliveryMethod || 'delivery',
special_instructions: data.specialInstructions || undefined,
is_active: true,
};
await OrdersService.createCustomer(currentTenant.id, payload);
showToast.success(i18next.t('wizards:customer.messages.customerCreatedSuccessfully'));
return true;
} catch (err: any) {
console.error('Error creating customer:', err);
const errorMessage = err.response?.data?.detail || i18next.t('wizards:customer.messages.errorCreatingCustomer');
showToast.error(errorMessage);
return false;
}
},
},
];

View File

@@ -19,6 +19,16 @@ const EquipmentDetailsStep: React.FC<WizardStepProps> = ({ dataRef, onDataChange
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="md:col-span-2">
<label className="block text-sm font-medium text-[var(--text-secondary)] mb-2">{t('equipment.fields.name')} *</label>
<input
type="text"
value={data.name || ''}
onChange={(e) => handleFieldChange('name', e.target.value)}
placeholder={t('equipment.fields.namePlaceholder')}
className="w-full px-3 py-2 border border-[var(--border-secondary)] rounded-lg focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)] bg-[var(--bg-primary)] text-[var(--text-primary)]"
/>
</div>
<div>
<label className="block text-sm font-medium text-[var(--text-secondary)] mb-2">{t('equipment.fields.type')} *</label>
<select
@@ -29,17 +39,18 @@ const EquipmentDetailsStep: React.FC<WizardStepProps> = ({ dataRef, onDataChange
<option value="oven">{t('equipment.types.oven')}</option>
<option value="mixer">{t('equipment.types.mixer')}</option>
<option value="proofer">{t('equipment.types.proofer')}</option>
<option value="refrigerator">{t('equipment.types.refrigerator')}</option>
<option value="freezer">{t('equipment.types.freezer')}</option>
<option value="packaging">{t('equipment.types.packaging')}</option>
<option value="other">{t('equipment.types.other')}</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-[var(--text-secondary)] mb-2">{t('equipment.fields.brand')}</label>
<label className="block text-sm font-medium text-[var(--text-secondary)] mb-2">{t('equipment.fields.model')}</label>
<input
type="text"
value={data.brand || ''}
onChange={(e) => handleFieldChange('brand', e.target.value)}
placeholder={t('equipment.fields.brandPlaceholder')}
value={data.model || ''}
onChange={(e) => handleFieldChange('model', e.target.value)}
placeholder={t('equipment.fields.modelPlaceholder')}
className="w-full px-3 py-2 border border-[var(--border-secondary)] rounded-lg focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)] bg-[var(--bg-primary)] text-[var(--text-primary)]"
/>
</div>
@@ -54,11 +65,11 @@ const EquipmentDetailsStep: React.FC<WizardStepProps> = ({ dataRef, onDataChange
/>
</div>
<div>
<label className="block text-sm font-medium text-[var(--text-secondary)] mb-2">{t('equipment.fields.purchaseDate')}</label>
<label className="block text-sm font-medium text-[var(--text-secondary)] mb-2">{t('equipment.fields.installDate')}</label>
<input
type="date"
value={data.purchaseDate || ''}
onChange={(e) => handleFieldChange('purchaseDate', e.target.value)}
value={data.installDate || ''}
onChange={(e) => handleFieldChange('installDate', e.target.value)}
className="w-full px-3 py-2 border border-[var(--border-secondary)] rounded-lg focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)] bg-[var(--bg-primary)] text-[var(--text-primary)]"
/>
</div>
@@ -68,52 +79,18 @@ const EquipmentDetailsStep: React.FC<WizardStepProps> = ({ dataRef, onDataChange
};
export const EquipmentWizardSteps = (dataRef: React.MutableRefObject<Record<string, any>>, setData: (data: Record<string, any>) => void): WizardStep[] => {
// New architecture: return direct component references instead of arrow functions
// dataRef and onDataChange are now passed through WizardModal props
// Wizard steps only handle UI and validation
// API submission is handled centrally in UnifiedAddWizard.handleWizardComplete
return [
{
id: 'equipment-details',
title: 'wizards:equipment.steps.equipmentDetails',
description: 'wizards:equipment.steps.equipmentDetailsDescription',
component: EquipmentDetailsStep,
validate: async () => {
const { useTenant } = await import('../../../../stores/tenant.store');
const { equipmentService } = await import('../../../../api/services/equipment');
const { showToast } = await import('../../../../utils/toast');
const i18next = (await import('i18next')).default;
validate: () => {
const data = dataRef.current;
const { currentTenant } = useTenant.getState();
if (!currentTenant?.id) {
showToast.error(i18next.t('wizards:equipment.messages.errorGettingTenant'));
return false;
}
try {
const equipmentCreateData: any = {
name: `${data.type || 'oven'} - ${data.brand || i18next.t('wizards:equipment.messages.noBrand')}`,
type: data.type || 'oven',
model: data.brand || '',
serialNumber: data.model || '',
location: data.location || '',
status: data.status || 'active',
installDate: data.purchaseDate || new Date().toISOString().split('T')[0],
lastMaintenance: new Date().toISOString().split('T')[0],
nextMaintenance: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString().split('T')[0],
maintenanceInterval: 30,
is_active: true
};
await equipmentService.createEquipment(currentTenant.id, equipmentCreateData);
showToast.success(i18next.t('wizards:equipment.messages.successCreate'));
return true;
} catch (err: any) {
console.error('Error creating equipment:', err);
const errorMessage = err.response?.data?.detail || i18next.t('wizards:equipment.messages.errorCreate');
showToast.error(errorMessage);
return false;
}
// Name and type are required
return !!(data.name && data.name.trim().length >= 2 && data.type);
},
},
];

View File

@@ -168,40 +168,40 @@ const RecipeDetailsStep: React.FC<WizardStepProps> = ({ dataRef, onDataChange })
{/* Advanced Options */}
<AdvancedOptionsSection
title="Advanced Options"
description="Optional fields for detailed recipe management"
title={t('recipe.advancedOptionsTitle')}
description={t('recipe.advancedOptionsDescription')}
>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-[var(--text-secondary)] mb-2">
Recipe Code/SKU
{t('recipe.fields.recipeCode')}
</label>
<input
type="text"
value={data.recipeCode}
onChange={(e) => handleFieldChange('recipeCode', e.target.value)}
placeholder="RCP-001"
placeholder={t('recipe.fields.recipeCodePlaceholder')}
className="w-full px-3 py-2 border border-[var(--border-secondary)] rounded-lg focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)] bg-[var(--bg-primary)] text-[var(--text-primary)]"
/>
</div>
<div>
<label className="block text-sm font-medium text-[var(--text-secondary)] mb-2">
Version
{t('recipe.fields.version')}
</label>
<input
type="text"
value={data.version}
onChange={(e) => handleFieldChange('version', e.target.value)}
placeholder="1.0"
placeholder={t('recipe.fields.versionPlaceholder')}
className="w-full px-3 py-2 border border-[var(--border-secondary)] rounded-lg focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)] bg-[var(--bg-primary)] text-[var(--text-primary)]"
/>
</div>
<div>
<label className="block text-sm font-medium text-[var(--text-secondary)] mb-2 inline-flex items-center gap-2">
Difficulty Level (1-5)
<Tooltip content="1 = Very Easy, 5 = Expert Level">
{t('recipe.fields.difficulty')}
<Tooltip content={t('recipe.fields.difficultyTooltip')}>
<span />
</Tooltip>
</label>
@@ -217,13 +217,13 @@ const RecipeDetailsStep: React.FC<WizardStepProps> = ({ dataRef, onDataChange })
<div>
<label className="block text-sm font-medium text-[var(--text-secondary)] mb-2">
Cook Time (minutes)
{t('recipe.fields.cookTime')}
</label>
<input
type="number"
value={data.cookTime}
onChange={(e) => handleFieldChange('cookTime', e.target.value)}
placeholder="30"
placeholder={t('recipe.fields.cookTimePlaceholder')}
min="0"
className="w-full px-3 py-2 border border-[var(--border-secondary)] rounded-lg focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)] bg-[var(--bg-primary)] text-[var(--text-primary)]"
/>
@@ -231,8 +231,8 @@ const RecipeDetailsStep: React.FC<WizardStepProps> = ({ dataRef, onDataChange })
<div>
<label className="block text-sm font-medium text-[var(--text-secondary)] mb-2 inline-flex items-center gap-2">
Rest Time (minutes)
<Tooltip content="Time for rising, cooling, or resting">
{t('recipe.fields.restTime')}
<Tooltip content={t('recipe.fields.restTimeTooltip')}>
<span />
</Tooltip>
</label>
@@ -240,7 +240,7 @@ const RecipeDetailsStep: React.FC<WizardStepProps> = ({ dataRef, onDataChange })
type="number"
value={data.restTime}
onChange={(e) => handleFieldChange('restTime', e.target.value)}
placeholder="60"
placeholder={t('recipe.fields.restTimePlaceholder')}
min="0"
className="w-full px-3 py-2 border border-[var(--border-secondary)] rounded-lg focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)] bg-[var(--bg-primary)] text-[var(--text-primary)]"
/>
@@ -248,13 +248,13 @@ const RecipeDetailsStep: React.FC<WizardStepProps> = ({ dataRef, onDataChange })
<div>
<label className="block text-sm font-medium text-[var(--text-secondary)] mb-2">
Total Time (minutes)
{t('recipe.fields.totalTime')}
</label>
<input
type="number"
value={data.totalTime}
onChange={(e) => handleFieldChange('totalTime', e.target.value)}
placeholder="90"
placeholder={t('recipe.fields.totalTimePlaceholder')}
min="0"
className="w-full px-3 py-2 border border-[var(--border-secondary)] rounded-lg focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)] bg-[var(--bg-primary)] text-[var(--text-primary)]"
/>
@@ -262,13 +262,13 @@ const RecipeDetailsStep: React.FC<WizardStepProps> = ({ dataRef, onDataChange })
<div>
<label className="block text-sm font-medium text-[var(--text-secondary)] mb-2">
Serves Count
{t('recipe.fields.servesCount')}
</label>
<input
type="number"
value={data.servesCount}
onChange={(e) => handleFieldChange('servesCount', e.target.value)}
placeholder="8"
placeholder={t('recipe.fields.servesCountPlaceholder')}
min="0"
className="w-full px-3 py-2 border border-[var(--border-secondary)] rounded-lg focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)] bg-[var(--bg-primary)] text-[var(--text-primary)]"
/>
@@ -276,8 +276,8 @@ const RecipeDetailsStep: React.FC<WizardStepProps> = ({ dataRef, onDataChange })
<div>
<label className="block text-sm font-medium text-[var(--text-secondary)] mb-2 inline-flex items-center gap-2">
Batch Size Multiplier
<Tooltip content="Default scaling factor for batch production">
{t('recipe.fields.batchSizeMultiplier')}
<Tooltip content={t('recipe.fields.batchSizeMultiplierTooltip')}>
<span />
</Tooltip>
</label>
@@ -285,7 +285,7 @@ const RecipeDetailsStep: React.FC<WizardStepProps> = ({ dataRef, onDataChange })
type="number"
value={data.batchSizeMultiplier}
onChange={(e) => handleFieldChange('batchSizeMultiplier', parseFloat(e.target.value) || 1)}
placeholder="1.0"
placeholder={t('recipe.fields.batchSizeMultiplierPlaceholder')}
min="0.1"
step="0.1"
className="w-full px-3 py-2 border border-[var(--border-secondary)] rounded-lg focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)] bg-[var(--bg-primary)] text-[var(--text-primary)]"
@@ -294,13 +294,13 @@ const RecipeDetailsStep: React.FC<WizardStepProps> = ({ dataRef, onDataChange })
<div>
<label className="block text-sm font-medium text-[var(--text-secondary)] mb-2">
Min Batch Size
{t('recipe.fields.minBatchSize')}
</label>
<input
type="number"
value={data.minBatchSize}
onChange={(e) => handleFieldChange('minBatchSize', e.target.value)}
placeholder="5"
placeholder={t('recipe.fields.minBatchSizePlaceholder')}
min="0"
step="0.1"
className="w-full px-3 py-2 border border-[var(--border-secondary)] rounded-lg focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)] bg-[var(--bg-primary)] text-[var(--text-primary)]"
@@ -309,13 +309,13 @@ const RecipeDetailsStep: React.FC<WizardStepProps> = ({ dataRef, onDataChange })
<div>
<label className="block text-sm font-medium text-[var(--text-secondary)] mb-2">
Max Batch Size
{t('recipe.fields.maxBatchSize')}
</label>
<input
type="number"
value={data.maxBatchSize}
onChange={(e) => handleFieldChange('maxBatchSize', e.target.value)}
placeholder="100"
placeholder={t('recipe.fields.maxBatchSizePlaceholder')}
min="0"
step="0.1"
className="w-full px-3 py-2 border border-[var(--border-secondary)] rounded-lg focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)] bg-[var(--bg-primary)] text-[var(--text-primary)]"
@@ -324,13 +324,13 @@ const RecipeDetailsStep: React.FC<WizardStepProps> = ({ dataRef, onDataChange })
<div>
<label className="block text-sm font-medium text-[var(--text-secondary)] mb-2">
Optimal Production Temp (°C)
{t('recipe.fields.optimalTemp')}
</label>
<input
type="number"
value={data.optimalProductionTemp}
onChange={(e) => handleFieldChange('optimalProductionTemp', e.target.value)}
placeholder="24"
placeholder={t('recipe.fields.optimalTempPlaceholder')}
step="0.1"
className="w-full px-3 py-2 border border-[var(--border-secondary)] rounded-lg focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)] bg-[var(--bg-primary)] text-[var(--text-primary)]"
/>
@@ -338,13 +338,13 @@ const RecipeDetailsStep: React.FC<WizardStepProps> = ({ dataRef, onDataChange })
<div>
<label className="block text-sm font-medium text-[var(--text-secondary)] mb-2">
Optimal Humidity (%)
{t('recipe.fields.optimalHumidity')}
</label>
<input
type="number"
value={data.optimalHumidity}
onChange={(e) => handleFieldChange('optimalHumidity', e.target.value)}
placeholder="65"
placeholder={t('recipe.fields.optimalHumidityPlaceholder')}
min="0"
max="100"
step="0.1"
@@ -354,13 +354,13 @@ const RecipeDetailsStep: React.FC<WizardStepProps> = ({ dataRef, onDataChange })
<div>
<label className="block text-sm font-medium text-[var(--text-secondary)] mb-2">
Target Margin (%)
{t('recipe.fields.targetMargin')}
</label>
<input
type="number"
value={data.targetMargin}
onChange={(e) => handleFieldChange('targetMargin', e.target.value)}
placeholder="30"
placeholder={t('recipe.fields.targetMarginPlaceholder')}
min="0"
max="100"
step="0.1"
@@ -379,7 +379,7 @@ const RecipeDetailsStep: React.FC<WizardStepProps> = ({ dataRef, onDataChange })
className="w-4 h-4 text-[var(--color-primary)] border-[var(--border-secondary)] rounded focus:ring-2 focus:ring-[var(--color-primary)]"
/>
<label htmlFor="isSeasonal" className="text-sm font-medium text-[var(--text-secondary)]">
Seasonal Item
{t('recipe.fields.seasonalItem')}
</label>
</div>
@@ -392,7 +392,7 @@ const RecipeDetailsStep: React.FC<WizardStepProps> = ({ dataRef, onDataChange })
className="w-4 h-4 text-[var(--color-primary)] border-[var(--border-secondary)] rounded focus:ring-2 focus:ring-[var(--color-primary)]"
/>
<label htmlFor="isSignatureItem" className="text-sm font-medium text-[var(--text-secondary)]">
Signature Item
{t('recipe.fields.signatureItem')}
</label>
</div>
</div>
@@ -401,14 +401,14 @@ const RecipeDetailsStep: React.FC<WizardStepProps> = ({ dataRef, onDataChange })
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-[var(--text-secondary)] mb-2">
Season Start Month
{t('recipe.fields.seasonStartMonth')}
</label>
<select
value={data.seasonStartMonth}
onChange={(e) => handleFieldChange('seasonStartMonth', e.target.value)}
className="w-full px-3 py-2 border border-[var(--border-secondary)] rounded-lg focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)] bg-[var(--bg-primary)] text-[var(--text-primary)]"
>
<option value="">Select month...</option>
<option value="">{t('recipe.fields.seasonStartMonthPlaceholder')}</option>
{Array.from({ length: 12 }, (_, i) => i + 1).map(month => (
<option key={month} value={month}>
{new Date(2000, month - 1).toLocaleString('default', { month: 'long' })}
@@ -419,14 +419,14 @@ const RecipeDetailsStep: React.FC<WizardStepProps> = ({ dataRef, onDataChange })
<div>
<label className="block text-sm font-medium text-[var(--text-secondary)] mb-2">
Season End Month
{t('recipe.fields.seasonEndMonth')}
</label>
<select
value={data.seasonEndMonth}
onChange={(e) => handleFieldChange('seasonEndMonth', e.target.value)}
className="w-full px-3 py-2 border border-[var(--border-secondary)] rounded-lg focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)] bg-[var(--bg-primary)] text-[var(--text-primary)]"
>
<option value="">Select month...</option>
<option value="">{t('recipe.fields.seasonEndMonthPlaceholder')}</option>
{Array.from({ length: 12 }, (_, i) => i + 1).map(month => (
<option key={month} value={month}>
{new Date(2000, month - 1).toLocaleString('default', { month: 'long' })}
@@ -439,12 +439,12 @@ const RecipeDetailsStep: React.FC<WizardStepProps> = ({ dataRef, onDataChange })
<div>
<label className="block text-sm font-medium text-[var(--text-secondary)] mb-2">
Description
{t('recipe.fields.description')}
</label>
<textarea
value={data.description}
onChange={(e) => handleFieldChange('description', e.target.value)}
placeholder="Detailed description of the recipe..."
placeholder={t('recipe.fields.descriptionPlaceholder')}
className="w-full px-3 py-2 border border-[var(--border-secondary)] rounded-lg focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)] bg-[var(--bg-primary)] text-[var(--text-primary)]"
rows={3}
/>
@@ -452,15 +452,15 @@ const RecipeDetailsStep: React.FC<WizardStepProps> = ({ dataRef, onDataChange })
<div>
<label className="block text-sm font-medium text-[var(--text-secondary)] mb-2 inline-flex items-center gap-2">
Recipe Notes & Tips
<Tooltip content="General notes, tips, or context about this recipe (not step-by-step instructions)">
{t('recipe.fields.prepNotes')}
<Tooltip content={t('recipe.fields.prepNotesTooltip')}>
<span />
</Tooltip>
</label>
<textarea
value={data.preparationNotes}
onChange={(e) => handleFieldChange('preparationNotes', e.target.value)}
placeholder="e.g., 'Works best in humid conditions', 'Can be prepared a day ahead', 'Traditional family recipe'..."
placeholder={t('recipe.fields.prepNotesPlaceholder')}
className="w-full px-3 py-2 border border-[var(--border-secondary)] rounded-lg focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)] bg-[var(--bg-primary)] text-[var(--text-primary)]"
rows={3}
/>
@@ -468,12 +468,12 @@ const RecipeDetailsStep: React.FC<WizardStepProps> = ({ dataRef, onDataChange })
<div>
<label className="block text-sm font-medium text-[var(--text-secondary)] mb-2">
Storage Instructions
{t('recipe.fields.storageInstructions')}
</label>
<textarea
value={data.storageInstructions}
onChange={(e) => handleFieldChange('storageInstructions', e.target.value)}
placeholder="How to store the finished product..."
placeholder={t('recipe.fields.storageInstructionsPlaceholder')}
className="w-full px-3 py-2 border border-[var(--border-secondary)] rounded-lg focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)] bg-[var(--bg-primary)] text-[var(--text-primary)]"
rows={3}
/>
@@ -481,26 +481,26 @@ const RecipeDetailsStep: React.FC<WizardStepProps> = ({ dataRef, onDataChange })
<div>
<label className="block text-sm font-medium text-[var(--text-secondary)] mb-2">
Allergens
{t('recipe.fields.allergens')}
</label>
<input
type="text"
value={data.allergens}
onChange={(e) => handleFieldChange('allergens', e.target.value)}
placeholder="e.g., gluten, dairy, eggs (comma-separated)"
placeholder={t('recipe.fields.allergensPlaceholder')}
className="w-full px-3 py-2 border border-[var(--border-secondary)] rounded-lg focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)] bg-[var(--bg-primary)] text-[var(--text-primary)]"
/>
</div>
<div>
<label className="block text-sm font-medium text-[var(--text-secondary)] mb-2">
Dietary Tags
{t('recipe.fields.dietaryTags')}
</label>
<input
type="text"
value={data.dietaryTags}
onChange={(e) => handleFieldChange('dietaryTags', e.target.value)}
placeholder="e.g., vegan, gluten-free, organic (comma-separated)"
placeholder={t('recipe.fields.dietaryTagsPlaceholder')}
className="w-full px-3 py-2 border border-[var(--border-secondary)] rounded-lg focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)] bg-[var(--bg-primary)] text-[var(--text-primary)]"
/>
</div>
@@ -769,13 +769,13 @@ const IngredientsStep: React.FC<WizardStepProps> = ({ dataRef, onDataChange }) =
};
// New Step 4: Enhanced Quality Control Configuration
const QualityControlStep: React.FC<WizardStepProps> = ({ dataRef, onDataChange, onComplete }) => {
// API submission is handled centrally in UnifiedAddWizard.handleWizardComplete
const QualityControlStep: React.FC<WizardStepProps> = ({ dataRef, onDataChange }) => {
const { t } = useTranslation('wizards');
const data = dataRef?.current || {};
const { currentTenant } = useTenant();
const [templates, setTemplates] = useState<QualityCheckTemplate[]>([]);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
@@ -800,76 +800,6 @@ const QualityControlStep: React.FC<WizardStepProps> = ({ dataRef, onDataChange,
onDataChange?.({ ...data, qualityConfiguration: config });
};
const handleCreateRecipe = async () => {
if (!currentTenant?.id) {
setError('Could not obtain tenant information');
return;
}
setSaving(true);
setError(null);
try {
const recipeIngredients: RecipeIngredientCreate[] = data.ingredients.map((ing: any, index: number) => ({
ingredient_id: ing.ingredientId,
quantity: ing.quantity,
unit: ing.unit,
ingredient_notes: ing.notes || null,
preparation_method: ing.preparationMethod || null,
is_optional: ing.isOptional || false,
ingredient_order: index + 1,
}));
// Use the quality configuration from the editor if available
const qualityConfig: RecipeQualityConfiguration | undefined = data.qualityConfiguration;
const recipeData: RecipeCreate = {
name: data.name,
category: data.category,
finished_product_id: data.finishedProductId,
yield_quantity: parseFloat(data.yieldQuantity),
yield_unit: data.yieldUnit as MeasurementUnit,
version: data.version || '1.0',
difficulty_level: data.difficultyLevel || 3,
prep_time_minutes: data.prepTime ? parseInt(data.prepTime) : null,
cook_time_minutes: data.cookTime ? parseInt(data.cookTime) : null,
rest_time_minutes: data.restTime ? parseInt(data.restTime) : null,
total_time_minutes: data.totalTime ? parseInt(data.totalTime) : null,
recipe_code: data.recipeCode || null,
description: data.description || null,
preparation_notes: data.preparationNotes || null,
storage_instructions: data.storageInstructions || null,
serves_count: data.servesCount ? parseInt(data.servesCount) : null,
batch_size_multiplier: data.batchSizeMultiplier || 1.0,
minimum_batch_size: data.minBatchSize ? parseFloat(data.minBatchSize) : null,
maximum_batch_size: data.maxBatchSize ? parseFloat(data.maxBatchSize) : null,
optimal_production_temperature: data.optimalProductionTemp ? parseFloat(data.optimalProductionTemp) : null,
optimal_humidity: data.optimalHumidity ? parseFloat(data.optimalHumidity) : null,
target_margin_percentage: data.targetMargin ? parseFloat(data.targetMargin) : null,
is_seasonal: data.isSeasonal || false,
is_signature_item: data.isSignatureItem || false,
season_start_month: data.seasonStartMonth ? parseInt(data.seasonStartMonth) : null,
season_end_month: data.seasonEndMonth ? parseInt(data.seasonEndMonth) : null,
instructions: data.instructions || null,
allergen_info: data.allergens ? data.allergens.split(',').map((a: string) => a.trim()) : null,
dietary_tags: data.dietaryTags ? data.dietaryTags.split(',').map((t: string) => t.trim()) : null,
ingredients: recipeIngredients,
quality_check_configuration: qualityConfig,
};
await recipesService.createRecipe(currentTenant.id, recipeData);
showToast.success(t('recipe.messages.successCreate'));
onComplete();
} catch (err: any) {
console.error('Error creating recipe:', err);
const errorMessage = err.response?.data?.detail || t('recipe.messages.errorCreate');
setError(errorMessage);
showToast.error(errorMessage);
} finally {
setSaving(false);
}
};
// Format templates for the editor
const formattedTemplates = templates.map(t => ({
id: t.id,
@@ -921,27 +851,6 @@ const QualityControlStep: React.FC<WizardStepProps> = ({ dataRef, onDataChange,
</div>
</>
)}
<div className="flex justify-center pt-4 border-t border-[var(--border-primary)]">
<button
type="button"
onClick={handleCreateRecipe}
disabled={saving || loading}
className="px-8 py-3 bg-green-600 text-white rounded-lg hover:bg-green-700 font-semibold inline-flex items-center gap-2 disabled:opacity-50 disabled:cursor-not-allowed"
>
{saving ? (
<>
<Loader2 className="w-5 h-5 animate-spin" />
Creating recipe...
</>
) : (
<>
<CheckCircle2 className="w-5 h-5" />
Create Recipe
</>
)}
</button>
</div>
</div>
);
};

View File

@@ -788,8 +788,8 @@ export const SalesEntryWizardSteps = (
): WizardStep[] => {
const entryMethod = dataRef.current.entryMethod;
// New architecture: return direct component references instead of arrow functions
// dataRef and onDataChange are now passed through WizardModal props
// Wizard steps only handle UI and validation
// API submission is handled centrally in UnifiedAddWizard.handleWizardComplete
const steps: WizardStep[] = [
{
id: 'entry-method',
@@ -824,52 +824,14 @@ export const SalesEntryWizardSteps = (
title: 'salesEntry.steps.review',
description: 'salesEntry.steps.reviewDescription',
component: ReviewStep,
validate: async () => {
const { useTenant } = await import('../../../../stores/tenant.store');
const { salesService } = await import('../../../../api/services/sales');
const { showToast } = await import('../../../../utils/toast');
validate: () => {
const data = dataRef.current;
const { currentTenant } = useTenant.getState();
if (!currentTenant?.id) {
const { showToast } = await import('../../../../utils/toast');
showToast.error('No se pudo obtener información del tenant');
return false;
// Validate based on entry method
if (data.entryMethod === 'manual') {
return (data.salesItems || []).length > 0;
}
try {
if (data.entryMethod === 'manual' && data.salesItems) {
// Create individual sales records for each item
for (const item of data.salesItems) {
const salesData = {
inventory_product_id: item.productId || null,
product_name: item.product,
product_category: 'general',
quantity_sold: item.quantity,
unit_price: item.unitPrice,
total_amount: item.subtotal,
sale_date: data.saleDate,
sales_channel: 'retail',
source: 'manual',
payment_method: data.paymentMethod,
notes: data.notes,
};
await salesService.createSalesRecord(currentTenant.id, salesData);
}
}
const { showToast } = await import('../../../../utils/toast');
showToast.success('Registro de ventas guardado exitosamente');
// For file upload, validation was done during upload
return true;
} catch (err: any) {
console.error('Error saving sales data:', err);
const { showToast } = await import('../../../../utils/toast');
const errorMessage = err.response?.data?.detail || 'Error al guardar los datos de ventas';
showToast.error(errorMessage);
return false;
}
},
});

View File

@@ -1,20 +1,14 @@
import React, { useState, useEffect } from 'react';
import React, { useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { WizardStep, WizardStepProps } from '../../../ui/WizardModal/WizardModal';
import { Building2, CheckCircle2, Loader2 } from 'lucide-react';
import { useTenant } from '../../../../stores/tenant.store';
import { suppliersService } from '../../../../api/services/suppliers';
import { showToast } from '../../../../utils/toast';
import { Building2 } from 'lucide-react';
import { AdvancedOptionsSection } from '../../../ui/AdvancedOptionsSection';
import Tooltip from '../../../ui/Tooltip/Tooltip';
const SupplierDetailsStep: React.FC<WizardStepProps> = ({ dataRef, onDataChange, onComplete }) => {
const SupplierDetailsStep: React.FC<WizardStepProps> = ({ dataRef, onDataChange }) => {
const { t } = useTranslation('wizards');
// New architecture: access data from dataRef.current
const data = dataRef?.current || {};
const { currentTenant } = useTenant();
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const handleFieldChange = (field: string, value: any) => {
onDataChange?.({ ...data, [field]: value });
@@ -27,64 +21,7 @@ const SupplierDetailsStep: React.FC<WizardStepProps> = ({ dataRef, onDataChange,
}
}, [data.name]);
const handleCreateSupplier = async () => {
const i18next = (await import('i18next')).default;
if (!currentTenant?.id) {
const errorMsg = i18next.t('wizards:supplier.messages.errorObtainingTenantInfo');
setError(errorMsg);
return;
}
setLoading(true);
setError(null);
try {
const payload = {
name: data.name,
supplier_type: data.supplierType,
status: data.status,
payment_terms: data.paymentTerms,
currency: data.currency,
standard_lead_time: data.standardLeadTime,
supplier_code: data.supplierCode || undefined,
tax_id: data.taxId || undefined,
registration_number: data.registrationNumber || undefined,
contact_person: data.contactPerson || undefined,
email: data.email || undefined,
phone: data.phone || undefined,
mobile: data.mobile || undefined,
website: data.website || undefined,
address_line1: data.addressLine1 || undefined,
address_line2: data.addressLine2 || undefined,
city: data.city || undefined,
state_province: data.stateProvince || undefined,
postal_code: data.postalCode || undefined,
country: data.country || undefined,
credit_limit: data.creditLimit ? parseFloat(data.creditLimit) : undefined,
minimum_order_amount: data.minimumOrderAmount ? parseFloat(data.minimumOrderAmount) : undefined,
delivery_area: data.deliveryArea || undefined,
is_preferred_supplier: data.isPreferredSupplier,
auto_approve_enabled: data.autoApproveEnabled,
notes: data.notes || undefined,
certifications: data.certifications ? JSON.parse(`{"items": ${JSON.stringify(data.certifications.split(',').map(c => c.trim()))}}`) : undefined,
specializations: data.specializations ? JSON.parse(`{"items": ${JSON.stringify(data.specializations.split(',').map(s => s.trim()))}}`) : undefined,
created_by: currentTenant.id,
updated_by: currentTenant.id,
};
await suppliersService.createSupplier(currentTenant.id, payload);
showToast.success(i18next.t('wizards:supplier.messages.supplierCreatedSuccessfully'));
// Let the wizard handle completion via the Next/Complete button
} catch (err: any) {
console.error('Error creating supplier:', err);
const errorMessage = err.response?.data?.detail || i18next.t('wizards:supplier.messages.errorCreatingSupplier');
setError(errorMessage);
showToast.error(errorMessage);
} finally {
setLoading(false);
}
};
// API submission is handled centrally in UnifiedAddWizard.handleWizardComplete
return (
<div className="space-y-6">
@@ -94,12 +31,6 @@ const SupplierDetailsStep: React.FC<WizardStepProps> = ({ dataRef, onDataChange,
<p className="text-sm text-[var(--text-secondary)]">{t('supplier.subtitle')}</p>
</div>
{error && (
<div className="p-3 bg-red-50 border border-red-200 rounded-lg text-red-700 text-sm">
{error}
</div>
)}
{/* Required Fields */}
<div className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">

View File

@@ -142,8 +142,8 @@ const PermissionsStep: React.FC<WizardStepProps> = ({ dataRef, onDataChange }) =
};
export const TeamMemberWizardSteps = (dataRef: React.MutableRefObject<Record<string, any>>, setData: (data: Record<string, any>) => void): WizardStep[] => {
// New architecture: return direct component references instead of arrow functions
// dataRef and onDataChange are now passed through WizardModal props
// Wizard steps only handle UI and validation
// API submission is handled centrally in UnifiedAddWizard.handleWizardComplete
return [
{
id: 'member-details',
@@ -156,49 +156,19 @@ export const TeamMemberWizardSteps = (dataRef: React.MutableRefObject<Record<str
title: 'wizards:teamMember.steps.roleAndPermissions',
description: 'wizards:teamMember.steps.roleAndPermissionsDescription',
component: PermissionsStep,
validate: async () => {
const { useTenant } = await import('../../../../stores/tenant.store');
const { authService } = await import('../../../../api/services/auth');
const { showToast } = await import('../../../../utils/toast');
const i18next = (await import('i18next')).default;
validate: () => {
const data = dataRef.current;
const { currentTenant } = useTenant.getState();
if (!currentTenant?.id) {
showToast.error(i18next.t('wizards:teamMember.messages.errorGettingTenant'));
// Validate required fields
if (!data.fullName || data.fullName.trim().length < 1) {
return false;
}
if (!data.email || !data.email.includes('@')) {
return false;
}
if (!data.role) {
return false;
}
try {
// Generate a temporary password (in production, this should be sent via email)
const tempPassword = `Temp${Math.random().toString(36).substring(2, 10)}!`;
// Register the new team member
const registrationData = {
email: data.email,
password: tempPassword,
full_name: data.fullName,
phone_number: data.phone || undefined,
tenant_id: currentTenant.id,
role: data.role,
};
await authService.register(registrationData);
// In a real implementation, you would:
// 1. Send email with temporary password
// 2. Store permissions in a separate permissions table
// 3. Link user to tenant with specific role
showToast.success(i18next.t('wizards:teamMember.messages.successCreate'));
return true;
} catch (err: any) {
console.error('Error creating team member:', err);
const errorMessage = err.response?.data?.detail || i18next.t('wizards:teamMember.messages.errorCreate');
showToast.error(errorMessage);
return false;
}
},
},
];

View File

@@ -14,7 +14,7 @@
"subtitle_option_b": "AI that knows your area predicts sales with 92% accuracy. Wake up with your plan ready: what to make, what to order, when it arrives. Save €500-2,000/month on waste.",
"cta_primary": "Join Pilot Program",
"cta_secondary": "See How It Works (2 min)",
"cta_demo": "See Demo",
"cta_demo": "View Interactive Demo",
"social_proof": {
"bakeries": "Reduce waste up to 40% from the first month",
"accuracy": "92% accurate predictions (vs 60% generic systems)",
@@ -225,10 +225,10 @@
},
"final_cta": {
"scarcity_badge": "🔥 Only 12 spots left out of 20",
"title": "Stop Losing €2,000 per Month on Waste",
"title": "Stop Losing Money per Month on Waste",
"title_accent": "Trying This Technology",
"subtitle": "Join the first 20 bakeries. Only 12 spots left.",
"button": "Start Now - No Card Required",
"subtitle": "Join the first 20 bakeries.",
"button": "Start Now",
"cta_primary": "Request Pilot Spot",
"cta_secondary": "See Demo (2 min)",
"guarantee": "Card required. No charge for 3 months. Cancel anytime."

View File

@@ -994,6 +994,40 @@
}
}
},
"equipment": {
"title": "Add Equipment",
"equipmentDetails": "Equipment Details",
"subtitle": "Bakery Equipment",
"fields": {
"name": "Equipment Name",
"namePlaceholder": "E.g., Main Production Oven",
"type": "Equipment Type",
"model": "Model",
"modelPlaceholder": "E.g., Rational SCC 101",
"location": "Location",
"locationPlaceholder": "E.g., Main kitchen",
"status": "Status",
"installDate": "Install Date"
},
"types": {
"oven": "Oven",
"mixer": "Mixer",
"proofer": "Proofer",
"freezer": "Freezer",
"packaging": "Packaging",
"other": "Other"
},
"steps": {
"equipmentDetails": "Equipment Details",
"equipmentDetailsDescription": "Type, model, location"
},
"messages": {
"errorGettingTenant": "Could not get tenant information",
"noBrand": "No brand",
"successCreate": "Equipment created successfully",
"errorCreate": "Error creating equipment"
}
},
"purchaseOrder": {
"orderItems": {
"titleHeader": "Products to Purchase",

View File

@@ -14,7 +14,7 @@
"subtitle_option_b": "IA que conoce tu zona predice ventas con 92% de precisión. Despierta con tu plan listo: qué hacer, qué pedir, cuándo llegará. Ahorra €500-2,000/mes en desperdicios.",
"cta_primary": "Únete al Programa Piloto",
"cta_secondary": "Ver Cómo Funciona (2 min)",
"cta_demo": "Ver Demo",
"cta_demo": "Ver Demostración interactiva",
"social_proof": {
"bakeries": "Reduce desperdicios hasta 40% desde el primer mes",
"accuracy": "Predicciones 92% precisas (vs 60% sistemas genéricos)",
@@ -225,10 +225,10 @@
},
"final_cta": {
"scarcity_badge": "🔥 Solo 12 plazas restantes de 20",
"title": "Deja de Perder €2,000 al Mes en Desperdicios",
"title": "Deja de Perder Dinero al Mes en Desperdicios",
"title_accent": "En Probar Esta Tecnología",
"subtitle": "Únete a las primeras 20 panaderías. Solo quedan 12 plazas.",
"button": "Comenzar Ahora - Sin Tarjeta Requerida",
"subtitle": "Únete a las primeras 20 panaderías.",
"button": "Comenzar Ahora",
"cta_primary": "Solicitar Plaza en el Piloto",
"cta_secondary": "Ver Demo (2 min)",
"guarantee": "Tarjeta requerida. Sin cargo por 3 meses. Cancela cuando quieras."

View File

@@ -1118,14 +1118,26 @@
"equipmentDetails": "Detalles de la Maquinaria",
"subtitle": "Equipo de Panadería",
"fields": {
"name": "Nombre del Equipo",
"namePlaceholder": "Ej: Horno Principal de Producción",
"type": "Tipo de Equipo",
"brand": "Marca/Modelo",
"brandPlaceholder": "Ej: Rational SCC 101",
"model": "Modelo",
"modelPlaceholder": "Ej: Rational SCC 101",
"location": "Ubicación",
"locationPlaceholder": "Ej: Cocina principal",
"status": "Estado",
"purchaseDate": "Fecha de Compra"
"purchaseDate": "Fecha de Compra",
"installDate": "Fecha de Instalación"
},
"types": {
"oven": "Horno",
"mixer": "Amasadora",
"proofer": "Fermentadora",
"freezer": "Congelador",
"packaging": "Empaquetadora",
"other": "Otro"
},
"equipmentTypes": {
"oven": "Horno",

View File

@@ -14,7 +14,7 @@
"subtitle_option_b": "Zure eremua ezagutzen duen IAk salmentak aurreikusten ditu %92ko zehaztasunarekin. Esnatu zure plana prestekin: zer egin, zer eskatu, noiz helduko den. Aurreztu €500-2,000/hilean hondakinetan.",
"cta_primary": "Eskatu Pilotuko Plaza",
"cta_secondary": "Ikusi Nola Lan Egiten Duen (2 min)",
"cta_demo": "Ikusi Demoa",
"cta_demo": "Ikusi Demo interaktiboa",
"social_proof": {
"bakeries": "Murriztu hondakinak %40 arte lehen hilabetatik",
"accuracy": "Aurreikuspen %92 zehatzak (%60 sistema generikoak)",
@@ -224,10 +224,10 @@
},
"final_cta": {
"scarcity_badge": "🔥 20tik 12 plaza bakarrik geratzen dira",
"title": "Utzi Hilean €2,000 Galtzea Hondakinetan",
"title": "Utzi Hilean Hondakinetan Dirua Galtzeaz",
"title_accent": "Teknologia Hau Probatzen",
"subtitle": "Batu lehenengo 20 okindegiei. 12 plaza bakarrik geratzen dira.",
"button": "Hasi Orain - Txartelik Gabe",
"subtitle": "Batu lehenengo 20 okindegiei.",
"button": "Hasi Orain",
"cta_primary": "Eskatu Pilotuko Plaza",
"cta_secondary": "Ikusi Demoa (2 min)",
"guarantee": "Txartela beharrezkoa. 3 hilabetez kargurik gabe. Ezeztatu edonoiz."

View File

@@ -3,6 +3,7 @@ import {
ShoppingCart,
TrendingUp,
AlertCircle,
AlertTriangle,
Target,
DollarSign,
Award,