diff --git a/frontend/src/components/domain/unified-wizard/ItemTypeSelector.tsx b/frontend/src/components/domain/unified-wizard/ItemTypeSelector.tsx index b11b48d3..74b52d9f 100644 --- a/frontend/src/components/domain/unified-wizard/ItemTypeSelector.tsx +++ b/frontend/src/components/domain/unified-wizard/ItemTypeSelector.tsx @@ -2,14 +2,14 @@ import React from 'react'; import { useTranslation } from 'react-i18next'; import { Package, - Building2, + Building, ChefHat, Wrench, ClipboardCheck, ShoppingCart, Users, UserPlus, - Euro, + Euro as EuroIcon, Sparkles, } from 'lucide-react'; @@ -39,7 +39,7 @@ export const ITEM_TYPES: ItemTypeConfig[] = [ id: 'sales-entry', title: 'Registro de Ventas', subtitle: 'Manual o carga masiva', - icon: Euro, + icon: EuroIcon, badge: '⭐ Más Común', badgeColor: 'bg-gradient-to-r from-amber-100 to-orange-100 text-orange-800 font-semibold', isHighlighted: true, @@ -56,7 +56,7 @@ export const ITEM_TYPES: ItemTypeConfig[] = [ id: 'supplier', title: 'Proveedor', subtitle: 'Relación comercial', - icon: Building2, + icon: Building, badge: 'Configuración', badgeColor: 'bg-blue-100 text-blue-700', }, @@ -117,83 +117,6 @@ interface ItemTypeSelectorProps { export const ItemTypeSelector: React.FC = ({ onSelect }) => { const { t } = useTranslation('wizards'); - // Generate item types from translations - const itemTypes: ItemTypeConfig[] = [ - { - id: 'sales-entry', - title: t('itemTypeSelector.types.sales-entry.title'), - subtitle: t('itemTypeSelector.types.sales-entry.description'), - icon: Euro, - badge: '⭐ ' + t('itemTypeSelector.mostCommon', { defaultValue: 'Most Common' }), - badgeColor: 'bg-gradient-to-r from-amber-100 to-orange-100 text-orange-800 font-semibold', - isHighlighted: true, - }, - { - id: 'inventory', - title: t('itemTypeSelector.types.inventory.title'), - subtitle: t('itemTypeSelector.types.inventory.description'), - icon: Package, - badge: t('itemTypeSelector.configuration', { defaultValue: 'Configuration' }), - badgeColor: 'bg-blue-100 text-blue-700', - }, - { - id: 'supplier', - title: t('itemTypeSelector.types.supplier.title'), - subtitle: t('itemTypeSelector.types.supplier.description'), - icon: Building2, - badge: t('itemTypeSelector.configuration', { defaultValue: 'Configuration' }), - badgeColor: 'bg-blue-100 text-blue-700', - }, - { - id: 'recipe', - title: t('itemTypeSelector.types.recipe.title'), - subtitle: t('itemTypeSelector.types.recipe.description'), - icon: ChefHat, - badge: t('itemTypeSelector.common', { defaultValue: 'Common' }), - badgeColor: 'bg-green-100 text-green-700', - }, - { - id: 'equipment', - title: t('itemTypeSelector.types.equipment.title'), - subtitle: t('itemTypeSelector.types.equipment.description'), - icon: Wrench, - badge: t('itemTypeSelector.configuration', { defaultValue: 'Configuration' }), - badgeColor: 'bg-blue-100 text-blue-700', - }, - { - id: 'quality-template', - title: t('itemTypeSelector.types.quality-template.title'), - subtitle: t('itemTypeSelector.types.quality-template.description'), - icon: ClipboardCheck, - badge: t('itemTypeSelector.configuration', { defaultValue: 'Configuration' }), - badgeColor: 'bg-blue-100 text-blue-700', - }, - { - id: 'customer-order', - title: t('itemTypeSelector.types.customer-order.title'), - subtitle: t('itemTypeSelector.types.customer-order.description'), - icon: ShoppingCart, - badge: t('itemTypeSelector.daily', { defaultValue: 'Daily' }), - badgeColor: 'bg-amber-100 text-amber-700', - }, - { - id: 'customer', - title: t('itemTypeSelector.types.customer.title'), - subtitle: t('itemTypeSelector.types.customer.description'), - icon: Users, - badge: t('itemTypeSelector.common', { defaultValue: 'Common' }), - badgeColor: 'bg-green-100 text-green-700', - }, - { - id: 'team-member', - title: t('itemTypeSelector.types.team-member.title'), - subtitle: t('itemTypeSelector.types.team-member.description'), - icon: UserPlus, - badge: t('itemTypeSelector.configuration', { defaultValue: 'Configuration' }), - badgeColor: 'bg-blue-100 text-blue-700', - }, - ]; - return (
{/* Header */} @@ -213,7 +136,7 @@ export const ItemTypeSelector: React.FC = ({ onSelect }) {/* Item Type Grid */}
- {itemTypes.map((itemType) => { + {ITEM_TYPES.map((itemType) => { const Icon = itemType.icon; const isHighlighted = itemType.isHighlighted; diff --git a/frontend/src/components/domain/unified-wizard/UnifiedAddWizard.tsx b/frontend/src/components/domain/unified-wizard/UnifiedAddWizard.tsx index 98cb0c7a..ccfa8c0d 100644 --- a/frontend/src/components/domain/unified-wizard/UnifiedAddWizard.tsx +++ b/frontend/src/components/domain/unified-wizard/UnifiedAddWizard.tsx @@ -1,10 +1,11 @@ -import React, { useState, useCallback, useMemo } from 'react'; +import React, { useState, useCallback, useMemo, useEffect, useRef } from 'react'; import { Sparkles } from 'lucide-react'; import { WizardModal, WizardStep } from '../../ui/WizardModal/WizardModal'; import { ItemTypeSelector, ItemType } from './ItemTypeSelector'; +import { AnyWizardData } from './types'; // Import specific wizards -import { InventoryWizardSteps } from './wizards/InventoryWizard'; +import { InventoryWizardSteps, ProductTypeStep, BasicInfoStep, StockConfigStep } from './wizards/InventoryWizard'; import { SupplierWizardSteps } from './wizards/SupplierWizard'; import { RecipeWizardSteps } from './wizards/RecipeWizard'; import { EquipmentWizardSteps } from './wizards/EquipmentWizard'; @@ -31,12 +32,22 @@ export const UnifiedAddWizard: React.FC = ({ const [selectedItemType, setSelectedItemType] = useState( initialItemType || null ); - const [wizardData, setWizardData] = useState>({}); + const [wizardData, setWizardData] = useState({}); + + // 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({}); + + // Update ref whenever data changes + useEffect(() => { + dataRef.current = wizardData; + }, [wizardData]); // Reset state when modal closes const handleClose = useCallback(() => { setSelectedItemType(initialItemType || null); setWizardData({}); + dataRef.current = {}; onClose(); }, [onClose, initialItemType]); @@ -45,11 +56,23 @@ export const UnifiedAddWizard: React.FC = ({ 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 const handleWizardComplete = useCallback( (data?: any) => { if (selectedItemType) { - onComplete?.(selectedItemType, data); + // On completion, sync the ref to state for submission + setWizardData(dataRef.current); + onComplete?.(selectedItemType, dataRef.current); } handleClose(); }, @@ -57,10 +80,10 @@ export const UnifiedAddWizard: React.FC = ({ ); // Get wizard steps based on selected item type - // CRITICAL: Memoize the steps to prevent component recreation on every render - // Without this, every keystroke causes the component to unmount/remount, losing focus - // IMPORTANT: For dynamic wizards (like sales-entry), we need to include the entryMethod - // in the dependency array so steps update when the user selects manual vs upload + // 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 @@ -76,30 +99,31 @@ export const UnifiedAddWizard: React.FC = ({ ]; } - // Return specific wizard steps based on selected type + // 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(wizardData, setWizardData); + return InventoryWizardSteps(dataRef, setWizardData); case 'supplier': - return SupplierWizardSteps(wizardData, setWizardData); + return SupplierWizardSteps(dataRef, setWizardData); case 'recipe': - return RecipeWizardSteps(wizardData, setWizardData); + return RecipeWizardSteps(dataRef, setWizardData); case 'equipment': - return EquipmentWizardSteps(wizardData, setWizardData); + return EquipmentWizardSteps(dataRef, setWizardData); case 'quality-template': - return QualityTemplateWizardSteps(wizardData, setWizardData); + return QualityTemplateWizardSteps(dataRef, setWizardData); case 'customer-order': - return CustomerOrderWizardSteps(wizardData, setWizardData); + return CustomerOrderWizardSteps(dataRef, setWizardData); case 'customer': - return CustomerWizardSteps(wizardData, setWizardData); + return CustomerWizardSteps(dataRef, setWizardData); case 'team-member': - return TeamMemberWizardSteps(wizardData, setWizardData); + return TeamMemberWizardSteps(dataRef, setWizardData); case 'sales-entry': - return SalesEntryWizardSteps(wizardData, setWizardData); + return SalesEntryWizardSteps(dataRef, setWizardData); default: return []; } - }, [selectedItemType, handleItemTypeSelect, wizardData.entryMethod]); // Include only critical fields for dynamic step generation + }, [selectedItemType, handleItemTypeSelect, wizardData.entryMethod]); // Add entryMethod for dynamic sales-entry steps // Get wizard title based on selected item type const getWizardTitle = (): string => { @@ -131,6 +155,8 @@ export const UnifiedAddWizard: React.FC = ({ steps={wizardSteps} icon={} size="xl" + dataRef={dataRef} + onDataChange={handleDataChange} /> ); }; diff --git a/frontend/src/components/domain/unified-wizard/shared/AddressFields.tsx b/frontend/src/components/domain/unified-wizard/shared/AddressFields.tsx new file mode 100644 index 00000000..66504eb5 --- /dev/null +++ b/frontend/src/components/domain/unified-wizard/shared/AddressFields.tsx @@ -0,0 +1,129 @@ +/** + * AddressFields - Reusable address form fields + * + * Used by: SupplierWizard, CustomerWizard, CustomerOrderWizard + */ + +import React from 'react'; +import { useTranslation } from 'react-i18next'; + +interface AddressFieldsProps { + data: { + address?: string; + city?: string; + state?: string; + postalCode?: string; + country?: string; + }; + onFieldChange: (field: string, value: string) => void; + required?: { + address?: boolean; + city?: boolean; + state?: boolean; + postalCode?: boolean; + country?: boolean; + }; + labels?: { + address?: string; + city?: string; + state?: string; + postalCode?: string; + country?: string; + }; + fieldPrefix?: string; // For delivery addresses: 'delivery' +} + +export const AddressFields: React.FC = ({ + data, + onFieldChange, + required = {}, + labels = {}, + fieldPrefix = '', +}) => { + const { t } = useTranslation('wizards'); + + const getFieldName = (field: string) => { + return fieldPrefix ? `${fieldPrefix}${field.charAt(0).toUpperCase()}${field.slice(1)}` : field; + }; + + return ( +
+ {/* Address */} +
+ + onFieldChange(getFieldName('address'), e.target.value)} + placeholder={t('common.fields.addressPlaceholder')} + 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)]" + /> +
+ + {/* City and State */} +
+
+ + onFieldChange(getFieldName('city'), e.target.value)} + placeholder={t('common.fields.cityPlaceholder')} + 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)]" + /> +
+ +
+ + onFieldChange(getFieldName('state'), e.target.value)} + placeholder={t('common.fields.statePlaceholder')} + 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)]" + /> +
+
+ + {/* Postal Code and Country */} +
+
+ + onFieldChange(getFieldName('postalCode'), e.target.value)} + placeholder={t('common.fields.postalCodePlaceholder')} + 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)]" + /> +
+ +
+ + onFieldChange(getFieldName('country'), e.target.value)} + placeholder={t('common.fields.countryPlaceholder')} + 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)]" + /> +
+
+
+ ); +}; diff --git a/frontend/src/components/domain/unified-wizard/shared/ContactInfoFields.tsx b/frontend/src/components/domain/unified-wizard/shared/ContactInfoFields.tsx new file mode 100644 index 00000000..ab5293e2 --- /dev/null +++ b/frontend/src/components/domain/unified-wizard/shared/ContactInfoFields.tsx @@ -0,0 +1,89 @@ +/** + * ContactInfoFields - Reusable contact information form fields + * + * Used by: SupplierWizard, CustomerWizard, TeamMemberWizard + */ + +import React from 'react'; +import { useTranslation } from 'react-i18next'; + +interface ContactInfoFieldsProps { + data: { + email?: string; + phone?: string; + contactName?: string; + }; + onFieldChange: (field: string, value: string) => void; + showContactName?: boolean; + required?: { + email?: boolean; + phone?: boolean; + contactName?: boolean; + }; + labels?: { + email?: string; + phone?: string; + contactName?: string; + }; +} + +export const ContactInfoFields: React.FC = ({ + data, + onFieldChange, + showContactName = false, + required = {}, + labels = {}, +}) => { + const { t } = useTranslation('wizards'); + + return ( +
+ {/* Contact Name (optional, shown for suppliers) */} + {showContactName && ( +
+ + onFieldChange('contactName', e.target.value)} + placeholder={t('common.fields.contactNamePlaceholder')} + 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)]" + /> +
+ )} + + {/* Email */} +
+ + onFieldChange('email', e.target.value)} + placeholder={t('common.fields.emailPlaceholder')} + 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)]" + /> +
+ + {/* Phone */} +
+ + onFieldChange('phone', e.target.value)} + placeholder={t('common.fields.phonePlaceholder')} + 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)]" + /> +
+
+ ); +}; diff --git a/frontend/src/components/domain/unified-wizard/shared/JsonEditor.tsx b/frontend/src/components/domain/unified-wizard/shared/JsonEditor.tsx new file mode 100644 index 00000000..d7eaa829 --- /dev/null +++ b/frontend/src/components/domain/unified-wizard/shared/JsonEditor.tsx @@ -0,0 +1,158 @@ +/** + * JsonEditor - Better UX for JSONB fields + * + * Provides syntax highlighting, validation, and error messages + * instead of raw textarea for JSON editing. + * + * Used by: QualityTemplateWizard, CustomerOrderWizard + */ + +import React, { useState, useEffect } from 'react'; +import { useTranslation } from 'react-i18next'; +import { AlertCircle, CheckCircle } from 'lucide-react'; + +interface JsonEditorProps { + value: any; + onChange: (value: any) => void; + label?: string; + placeholder?: string; + required?: boolean; + rows?: number; +} + +export const JsonEditor: React.FC = ({ + value, + onChange, + label, + placeholder, + required = false, + rows = 6, +}) => { + const { t } = useTranslation('wizards'); + const [jsonString, setJsonString] = useState(''); + const [error, setError] = useState(null); + const [isValid, setIsValid] = useState(true); + + // Initialize from value + useEffect(() => { + try { + if (value === null || value === undefined || value === '') { + setJsonString(''); + } else if (typeof value === 'string') { + // Try to parse to validate + JSON.parse(value); + setJsonString(value); + } else { + setJsonString(JSON.stringify(value, null, 2)); + } + setIsValid(true); + setError(null); + } catch (e) { + setJsonString(typeof value === 'string' ? value : ''); + setIsValid(false); + } + }, []); + + const handleChange = (newValue: string) => { + setJsonString(newValue); + + // Validate JSON + if (newValue.trim() === '') { + setError(null); + setIsValid(true); + onChange(null); + return; + } + + try { + const parsed = JSON.parse(newValue); + setError(null); + setIsValid(true); + onChange(parsed); + } catch (e) { + setError(e instanceof Error ? e.message : 'Invalid JSON'); + setIsValid(false); + // Don't update parent with invalid JSON + } + }; + + const formatJson = () => { + try { + const parsed = JSON.parse(jsonString); + const formatted = JSON.stringify(parsed, null, 2); + setJsonString(formatted); + setError(null); + setIsValid(true); + onChange(parsed); + } catch (e) { + setError(e instanceof Error ? e.message : 'Invalid JSON'); + setIsValid(false); + } + }; + + return ( +
+ {label && ( + + )} + +
+