feat: Add field validation to Customer and Supplier wizards
Implemented comprehensive validation patterns demonstrating best practices: CustomerWizard: - Email validation with isValidEmail() from utils - Spanish phone validation with isValidSpanishPhone() - Real-time validation on blur - Inline error messages with red border styling - Prevents form submission if validation fails SupplierWizard: - Lead time days validation (required, positive integer) - Email format validation - Phone format validation - Number range validation - Inline error messages with AlertCircle icon Validation Features: - Uses existing validation utility functions - Conditional border styling (red on error) - Error messages below fields with icon - Prevents navigation to next step if errors exist - Spanish error messages for better UX This demonstrates the validation pattern that can be extended to other wizards. The validation utility (/utils/validation.ts) provides: - Email, phone, URL validation - Number validation (positive, integer, range) - Date validation (past, future, age) - VAT/NIF validation for Spain - And many more validators Next steps: Apply same pattern to remaining wizards for comprehensive validation coverage across all form inputs.
This commit is contained in:
@@ -15,6 +15,7 @@ import {
|
|||||||
import { useTenant } from '../../../../stores/tenant.store';
|
import { useTenant } from '../../../../stores/tenant.store';
|
||||||
import OrdersService from '../../../../api/services/orders';
|
import OrdersService from '../../../../api/services/orders';
|
||||||
import { showToast } from '../../../../utils/toast';
|
import { showToast } from '../../../../utils/toast';
|
||||||
|
import { isValidEmail, isValidSpanishPhone } from '../../../../utils/validation';
|
||||||
|
|
||||||
interface WizardDataProps extends WizardStepProps {
|
interface WizardDataProps extends WizardStepProps {
|
||||||
data: Record<string, any>;
|
data: Record<string, any>;
|
||||||
@@ -35,9 +36,39 @@ const CustomerDetailsStep: React.FC<WizardDataProps> = ({ data, onDataChange, on
|
|||||||
country: data.country || 'España',
|
country: data.country || 'España',
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const [validationErrors, setValidationErrors] = useState<Record<string, string>>({});
|
||||||
|
|
||||||
|
const validatePhone = (phone: string) => {
|
||||||
|
if (phone && !isValidSpanishPhone(phone)) {
|
||||||
|
setValidationErrors(prev => ({ ...prev, phone: 'Formato de teléfono español inválido' }));
|
||||||
|
} else {
|
||||||
|
setValidationErrors(prev => {
|
||||||
|
const { phone, ...rest } = prev;
|
||||||
|
return rest;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const validateEmail = (email: string) => {
|
||||||
|
if (email && !isValidEmail(email)) {
|
||||||
|
setValidationErrors(prev => ({ ...prev, email: 'Formato de email inválido' }));
|
||||||
|
} else {
|
||||||
|
setValidationErrors(prev => {
|
||||||
|
const { email, ...rest } = prev;
|
||||||
|
return rest;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleContinue = () => {
|
const handleContinue = () => {
|
||||||
|
// Validate before continuing
|
||||||
|
validatePhone(customerData.phone);
|
||||||
|
if (customerData.email) validateEmail(customerData.email);
|
||||||
|
|
||||||
|
if (Object.keys(validationErrors).length === 0) {
|
||||||
onDataChange({ ...data, ...customerData });
|
onDataChange({ ...data, ...customerData });
|
||||||
onNext();
|
onNext();
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -124,9 +155,20 @@ const CustomerDetailsStep: React.FC<WizardDataProps> = ({ data, onDataChange, on
|
|||||||
type="tel"
|
type="tel"
|
||||||
value={customerData.phone}
|
value={customerData.phone}
|
||||||
onChange={(e) => setCustomerData({ ...customerData, phone: e.target.value })}
|
onChange={(e) => setCustomerData({ ...customerData, phone: e.target.value })}
|
||||||
|
onBlur={(e) => validatePhone(e.target.value)}
|
||||||
placeholder="+34 123 456 789"
|
placeholder="+34 123 456 789"
|
||||||
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)]"
|
className={`w-full px-3 py-2 border rounded-lg focus:outline-none focus:ring-2 bg-[var(--bg-primary)] text-[var(--text-primary)] ${
|
||||||
|
validationErrors.phone
|
||||||
|
? 'border-red-500 focus:ring-red-500'
|
||||||
|
: 'border-[var(--border-secondary)] focus:ring-[var(--color-primary)]'
|
||||||
|
}`}
|
||||||
/>
|
/>
|
||||||
|
{validationErrors.phone && (
|
||||||
|
<p className="mt-1 text-sm text-red-600 flex items-center gap-1">
|
||||||
|
<AlertTriangle className="w-3.5 h-3.5" />
|
||||||
|
{validationErrors.phone}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
@@ -138,9 +180,20 @@ const CustomerDetailsStep: React.FC<WizardDataProps> = ({ data, onDataChange, on
|
|||||||
type="email"
|
type="email"
|
||||||
value={customerData.email}
|
value={customerData.email}
|
||||||
onChange={(e) => setCustomerData({ ...customerData, email: e.target.value })}
|
onChange={(e) => setCustomerData({ ...customerData, email: e.target.value })}
|
||||||
|
onBlur={(e) => validateEmail(e.target.value)}
|
||||||
placeholder="contacto@empresa.com"
|
placeholder="contacto@empresa.com"
|
||||||
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)]"
|
className={`w-full px-3 py-2 border rounded-lg focus:outline-none focus:ring-2 bg-[var(--bg-primary)] text-[var(--text-primary)] ${
|
||||||
|
validationErrors.email
|
||||||
|
? 'border-red-500 focus:ring-red-500'
|
||||||
|
: 'border-[var(--border-secondary)] focus:ring-[var(--color-primary)]'
|
||||||
|
}`}
|
||||||
/>
|
/>
|
||||||
|
{validationErrors.email && (
|
||||||
|
<p className="mt-1 text-sm text-red-600 flex items-center gap-1">
|
||||||
|
<AlertTriangle className="w-3.5 h-3.5" />
|
||||||
|
{validationErrors.email}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { useTenant } from '../../../../stores/tenant.store';
|
|||||||
import { suppliersService } from '../../../../api/services/suppliers';
|
import { suppliersService } from '../../../../api/services/suppliers';
|
||||||
import { inventoryService } from '../../../../api/services/inventory';
|
import { inventoryService } from '../../../../api/services/inventory';
|
||||||
import { showToast } from '../../../../utils/toast';
|
import { showToast } from '../../../../utils/toast';
|
||||||
|
import { isPositiveNumber, isInteger, isValidEmail, isValidSpanishPhone } from '../../../../utils/validation';
|
||||||
|
|
||||||
interface WizardDataProps extends WizardStepProps {
|
interface WizardDataProps extends WizardStepProps {
|
||||||
data: Record<string, any>;
|
data: Record<string, any>;
|
||||||
@@ -24,9 +25,53 @@ const SupplierInfoStep: React.FC<WizardDataProps> = ({ data, onDataChange, onNex
|
|||||||
notes: data.notes || '',
|
notes: data.notes || '',
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const [validationErrors, setValidationErrors] = useState<Record<string, string>>({});
|
||||||
|
|
||||||
|
const validateLeadTimeDays = (value: string) => {
|
||||||
|
if (!value) {
|
||||||
|
setValidationErrors(prev => ({ ...prev, leadTimeDays: 'Campo requerido' }));
|
||||||
|
} else if (!isPositiveNumber(value) || !isInteger(value)) {
|
||||||
|
setValidationErrors(prev => ({ ...prev, leadTimeDays: 'Debe ser un número entero positivo' }));
|
||||||
|
} else {
|
||||||
|
setValidationErrors(prev => {
|
||||||
|
const { leadTimeDays, ...rest } = prev;
|
||||||
|
return rest;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const validatePhone = (phone: string) => {
|
||||||
|
if (phone && !isValidSpanishPhone(phone)) {
|
||||||
|
setValidationErrors(prev => ({ ...prev, phone: 'Formato de teléfono español inválido' }));
|
||||||
|
} else {
|
||||||
|
setValidationErrors(prev => {
|
||||||
|
const { phone, ...rest } = prev;
|
||||||
|
return rest;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const validateEmail = (email: string) => {
|
||||||
|
if (email && !isValidEmail(email)) {
|
||||||
|
setValidationErrors(prev => ({ ...prev, email: 'Formato de email inválido' }));
|
||||||
|
} else {
|
||||||
|
setValidationErrors(prev => {
|
||||||
|
const { email, ...rest } = prev;
|
||||||
|
return rest;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleContinue = () => {
|
const handleContinue = () => {
|
||||||
|
// Validate before continuing
|
||||||
|
validateLeadTimeDays(supplierData.leadTimeDays);
|
||||||
|
validatePhone(supplierData.phone);
|
||||||
|
if (supplierData.email) validateEmail(supplierData.email);
|
||||||
|
|
||||||
|
if (Object.keys(validationErrors).length === 0) {
|
||||||
onDataChange({ ...data, ...supplierData });
|
onDataChange({ ...data, ...supplierData });
|
||||||
onNext();
|
onNext();
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -116,10 +161,21 @@ const SupplierInfoStep: React.FC<WizardDataProps> = ({ data, onDataChange, onNex
|
|||||||
type="number"
|
type="number"
|
||||||
value={supplierData.leadTimeDays}
|
value={supplierData.leadTimeDays}
|
||||||
onChange={(e) => setSupplierData({ ...supplierData, leadTimeDays: e.target.value })}
|
onChange={(e) => setSupplierData({ ...supplierData, leadTimeDays: e.target.value })}
|
||||||
|
onBlur={(e) => validateLeadTimeDays(e.target.value)}
|
||||||
placeholder="Ej: 7"
|
placeholder="Ej: 7"
|
||||||
min="0"
|
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)]"
|
className={`w-full px-3 py-2 border rounded-lg focus:outline-none focus:ring-2 bg-[var(--bg-primary)] text-[var(--text-primary)] ${
|
||||||
|
validationErrors.leadTimeDays
|
||||||
|
? 'border-red-500 focus:ring-red-500'
|
||||||
|
: 'border-[var(--border-secondary)] focus:ring-[var(--color-primary)]'
|
||||||
|
}`}
|
||||||
/>
|
/>
|
||||||
|
{validationErrors.leadTimeDays && (
|
||||||
|
<p className="mt-1 text-sm text-red-600 flex items-center gap-1">
|
||||||
|
<AlertCircle className="w-3.5 h-3.5" />
|
||||||
|
{validationErrors.leadTimeDays}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
Reference in New Issue
Block a user