Update readmes and imporve UI

This commit is contained in:
Urtzi Alfaro
2025-12-19 09:28:36 +01:00
parent a6ae730ef0
commit 71ee2976a2
10 changed files with 1035 additions and 155 deletions

View File

@@ -1,4 +1,4 @@
import React from 'react';
import React, { useState, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import {
Package,
@@ -13,6 +13,8 @@ import {
Sparkles,
FileText,
Factory,
Search,
X,
} from 'lucide-react';
export type ItemType =
@@ -36,6 +38,8 @@ export interface ItemTypeConfig {
badge?: string;
badgeColor?: string;
isHighlighted?: boolean;
category: 'daily' | 'common' | 'setup';
keywords?: string[];
}
export const ITEM_TYPES: ItemTypeConfig[] = [
@@ -47,6 +51,8 @@ export const ITEM_TYPES: ItemTypeConfig[] = [
badge: '⭐ Más Común',
badgeColor: 'bg-gradient-to-r from-amber-100 to-orange-100 text-orange-800 font-semibold',
isHighlighted: true,
category: 'daily',
keywords: ['ventas', 'sales', 'ingresos', 'caja', 'revenue'],
},
{
id: 'inventory',
@@ -55,6 +61,8 @@ export const ITEM_TYPES: ItemTypeConfig[] = [
icon: Package,
badge: 'Configuración',
badgeColor: 'bg-blue-100 text-blue-700',
category: 'setup',
keywords: ['inventario', 'inventory', 'stock', 'ingredientes', 'productos'],
},
{
id: 'supplier',
@@ -63,6 +71,8 @@ export const ITEM_TYPES: ItemTypeConfig[] = [
icon: Building,
badge: 'Configuración',
badgeColor: 'bg-blue-100 text-blue-700',
category: 'setup',
keywords: ['proveedor', 'supplier', 'vendor', 'distribuidor'],
},
{
id: 'recipe',
@@ -71,6 +81,8 @@ export const ITEM_TYPES: ItemTypeConfig[] = [
icon: ChefHat,
badge: 'Común',
badgeColor: 'bg-green-100 text-green-700',
category: 'common',
keywords: ['receta', 'recipe', 'formula', 'producción'],
},
{
id: 'equipment',
@@ -79,6 +91,8 @@ export const ITEM_TYPES: ItemTypeConfig[] = [
icon: Wrench,
badge: 'Configuración',
badgeColor: 'bg-blue-100 text-blue-700',
category: 'setup',
keywords: ['equipo', 'equipment', 'maquinaria', 'horno', 'mixer'],
},
{
id: 'quality-template',
@@ -87,6 +101,8 @@ export const ITEM_TYPES: ItemTypeConfig[] = [
icon: ClipboardCheck,
badge: 'Configuración',
badgeColor: 'bg-blue-100 text-blue-700',
category: 'setup',
keywords: ['calidad', 'quality', 'control', 'estándares', 'inspección'],
},
{
id: 'customer-order',
@@ -95,6 +111,8 @@ export const ITEM_TYPES: ItemTypeConfig[] = [
icon: ShoppingCart,
badge: 'Diario',
badgeColor: 'bg-amber-100 text-amber-700',
category: 'daily',
keywords: ['pedido', 'order', 'cliente', 'customer', 'orden'],
},
{
id: 'customer',
@@ -103,6 +121,8 @@ export const ITEM_TYPES: ItemTypeConfig[] = [
icon: Users,
badge: 'Común',
badgeColor: 'bg-green-100 text-green-700',
category: 'common',
keywords: ['cliente', 'customer', 'comprador', 'contacto'],
},
{
id: 'team-member',
@@ -111,6 +131,8 @@ export const ITEM_TYPES: ItemTypeConfig[] = [
icon: UserPlus,
badge: 'Configuración',
badgeColor: 'bg-blue-100 text-blue-700',
category: 'setup',
keywords: ['empleado', 'employee', 'team', 'staff', 'usuario'],
},
{
id: 'purchase-order',
@@ -119,6 +141,8 @@ export const ITEM_TYPES: ItemTypeConfig[] = [
icon: FileText,
badge: 'Diario',
badgeColor: 'bg-amber-100 text-amber-700',
category: 'daily',
keywords: ['compra', 'purchase', 'orden', 'proveedor', 'abastecimiento'],
},
{
id: 'production-batch',
@@ -127,6 +151,8 @@ export const ITEM_TYPES: ItemTypeConfig[] = [
icon: Factory,
badge: 'Diario',
badgeColor: 'bg-amber-100 text-amber-700',
category: 'daily',
keywords: ['producción', 'production', 'lote', 'batch', 'fabricación'],
},
];
@@ -136,6 +162,36 @@ interface ItemTypeSelectorProps {
export const ItemTypeSelector: React.FC<ItemTypeSelectorProps> = ({ onSelect }) => {
const { t } = useTranslation('wizards');
const [searchQuery, setSearchQuery] = useState('');
const [selectedCategory, setSelectedCategory] = useState<'all' | 'daily' | 'common' | 'setup'>('all');
// Filter items based on search and category
const filteredItems = useMemo(() => {
return ITEM_TYPES.filter(item => {
// Category filter
if (selectedCategory !== 'all' && item.category !== selectedCategory) {
return false;
}
// Search filter
if (searchQuery.trim()) {
const query = searchQuery.toLowerCase();
const matchesTitle = item.title.toLowerCase().includes(query);
const matchesSubtitle = item.subtitle.toLowerCase().includes(query);
const matchesKeywords = item.keywords?.some(keyword => keyword.toLowerCase().includes(query));
return matchesTitle || matchesSubtitle || matchesKeywords;
}
return true;
});
}, [searchQuery, selectedCategory]);
const categoryLabels = {
all: 'Todos',
daily: 'Diario',
common: 'Común',
setup: 'Configuración',
};
return (
<div className="space-y-6">
@@ -154,9 +210,60 @@ export const ItemTypeSelector: React.FC<ItemTypeSelectorProps> = ({ onSelect })
</p>
</div>
{/* Search and Filters */}
<div className="space-y-3">
{/* Search Bar */}
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-[var(--text-tertiary)]" />
<input
type="text"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder="Buscar por nombre o categoría..."
className="w-full pl-10 pr-10 py-2.5 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)] placeholder-[var(--text-tertiary)]"
/>
{searchQuery && (
<button
onClick={() => setSearchQuery('')}
className="absolute right-3 top-1/2 -translate-y-1/2 p-1 hover:bg-[var(--bg-secondary)] rounded transition-colors"
>
<X className="w-4 h-4 text-[var(--text-tertiary)]" />
</button>
)}
</div>
{/* Category Filters */}
<div className="flex flex-wrap gap-2">
{(['all', 'daily', 'common', 'setup'] as const).map((category) => (
<button
key={category}
onClick={() => setSelectedCategory(category)}
className={`px-4 py-2 rounded-full text-sm font-medium transition-all ${
selectedCategory === category
? 'bg-[var(--color-primary)] text-white shadow-md'
: 'bg-[var(--bg-secondary)] text-[var(--text-secondary)] hover:bg-[var(--bg-tertiary)]'
}`}
>
{categoryLabels[category]}
</button>
))}
</div>
</div>
{/* Results Count */}
{(searchQuery || selectedCategory !== 'all') && (
<div className="text-sm text-[var(--text-secondary)]">
{filteredItems.length === 0 ? (
<p className="text-center py-4">No se encontraron resultados</p>
) : (
<p>{filteredItems.length} {filteredItems.length === 1 ? 'resultado' : 'resultados'}</p>
)}
</div>
)}
{/* Item Type Grid */}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3 md:gap-4">
{ITEM_TYPES.map((itemType) => {
{filteredItems.map((itemType) => {
const Icon = itemType.icon;
const isHighlighted = itemType.isHighlighted;

View File

@@ -97,26 +97,40 @@ export const UnifiedAddWizard: React.FC<UnifiedAddWizardProps> = ({
// Handle Purchase Order submission
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 + (item.subtotal || 0),
(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: finalData.required_delivery_date,
required_delivery_date: requiredDeliveryDateTime,
priority: finalData.priority || 'normal',
subtotal: String(subtotal),
tax_amount: String(finalData.tax_amount || 0),
shipping_cost: String(finalData.shipping_cost || 0),
discount_amount: String(finalData.discount_amount || 0),
subtotal: subtotal,
tax_amount: Number(finalData.tax_amount) || 0,
shipping_cost: Number(finalData.shipping_cost) || 0,
discount_amount: Number(finalData.discount_amount) || 0,
notes: finalData.notes || undefined,
items: (finalData.items || []).map((item: any) => ({
inventory_product_id: item.inventory_product_id,
ordered_quantity: item.ordered_quantity,
unit_price: String(item.unit_price),
ordered_quantity: Number(item.ordered_quantity),
unit_price: Number(item.unit_price),
unit_of_measure: item.unit_of_measure,
})),
},
@@ -126,17 +140,37 @@ export const UnifiedAddWizard: React.FC<UnifiedAddWizardProps> = ({
// Handle Production Batch submission
if (selectedItemType === 'production-batch') {
// Validate quantities
if (Number(finalData.planned_quantity) < 0.01) {
throw new Error('La cantidad planificada debe ser mayor a 0');
}
if (Number(finalData.planned_duration_minutes) < 1) {
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);
if (isNaN(plannedStartDate.getTime()) || isNaN(plannedEndDate.getTime())) {
throw new Error('Fechas de inicio o fin inválidas');
}
if (plannedEndDate <= plannedStartDate) {
throw new Error('La fecha de fin debe ser posterior a la fecha de inicio');
}
const batchData: ProductionBatchCreate = {
product_id: finalData.product_id,
product_name: finalData.product_name,
recipe_id: finalData.recipe_id || undefined,
planned_start_time: finalData.planned_start_time,
planned_end_time: finalData.planned_end_time,
planned_start_time: plannedStartDate.toISOString(),
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,

View File

@@ -702,10 +702,10 @@ export const PurchaseOrderWizardSteps = (
return 'Debes agregar al menos un producto';
}
const invalidItems = data.items.some(
(item: any) => !item.inventory_product_id || item.ordered_quantity <= 0 || item.unit_price <= 0
(item: any) => !item.inventory_product_id || item.ordered_quantity < 0.01 || item.unit_price < 0.01
);
if (invalidItems) {
return 'Todos los productos deben tener ingrediente, cantidad y precio válidos';
return 'Todos los productos deben tener ingrediente, cantidad mayor a 0 y precio mayor a 0';
}
return true;
},

View File

@@ -1,5 +1,5 @@
import React, { useState, useCallback } from 'react';
import { X, ChevronLeft, ChevronRight } from 'lucide-react';
import React, { useState, useCallback, useEffect } from 'react';
import { X, ChevronLeft, ChevronRight, AlertCircle, CheckCircle } from 'lucide-react';
export interface WizardStep {
id: string;
@@ -50,6 +50,8 @@ export const WizardModal: React.FC<WizardModalProps> = ({
}) => {
const [currentStepIndex, setCurrentStepIndex] = useState(0);
const [isValidating, setIsValidating] = useState(false);
const [validationError, setValidationError] = useState<string | null>(null);
const [validationSuccess, setValidationSuccess] = useState(false);
const currentStep = steps[currentStepIndex];
const isFirstStep = currentStepIndex === 0;
@@ -65,6 +67,8 @@ export const WizardModal: React.FC<WizardModalProps> = ({
const handleClose = useCallback(() => {
setCurrentStepIndex(0);
setValidationError(null);
setValidationSuccess(false);
onClose();
}, [onClose]);
@@ -80,6 +84,8 @@ export const WizardModal: React.FC<WizardModalProps> = ({
}, [steps.length]);
const handleBack = useCallback(() => {
setValidationError(null);
setValidationSuccess(false);
setCurrentStepIndex(prev => Math.max(prev - 1, 0));
}, []);
@@ -88,17 +94,26 @@ export const WizardModal: React.FC<WizardModalProps> = ({
const step = steps[currentStepIndex];
const lastStep = currentStepIndex === steps.length - 1;
// Clear previous validation messages
setValidationError(null);
setValidationSuccess(false);
// Validate current step if validator exists
if (step.validate) {
setIsValidating(true);
try {
const isValid = await step.validate();
if (!isValid) {
setValidationError('Por favor, completa todos los campos requeridos correctamente.');
setIsValidating(false);
return;
}
// Show brief success indicator
setValidationSuccess(true);
setTimeout(() => setValidationSuccess(false), 1000);
} catch (error) {
console.error('Validation error:', error);
setValidationError(error instanceof Error ? error.message : 'Error de validación. Por favor, verifica los campos.');
setIsValidating(false);
return;
}
@@ -112,6 +127,41 @@ export const WizardModal: React.FC<WizardModalProps> = ({
}
}, [steps, currentStepIndex, handleComplete]);
// Keyboard navigation
useEffect(() => {
if (!isOpen) return;
const handleKeyDown = (e: KeyboardEvent) => {
// Don't handle keyboard events if user is typing in an input/textarea
const target = e.target as HTMLElement;
if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.tagName === 'SELECT') {
return;
}
switch (e.key) {
case 'Escape':
handleClose();
break;
case 'ArrowLeft':
if (!isFirstStep && !isValidating) {
e.preventDefault();
handleBack();
}
break;
case 'ArrowRight':
case 'Enter':
if (!isValidating) {
e.preventDefault();
handleNext();
}
break;
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [isOpen, isFirstStep, isValidating, handleClose, handleBack, handleNext]);
if (!isOpen) return null;
const StepComponent = currentStep.component;
@@ -132,61 +182,113 @@ export const WizardModal: React.FC<WizardModalProps> = ({
onClick={(e) => e.stopPropagation()}
>
{/* Header */}
<div className="sticky top-0 z-10 bg-[var(--bg-primary)] border-b border-[var(--border-secondary)]">
<div className="sticky top-0 z-10 bg-[var(--bg-primary)] border-b border-[var(--border-secondary)] shadow-sm">
{/* Title Bar */}
<div className="flex items-center justify-between p-6 pb-4">
<div className="flex items-center gap-3">
<div className="flex items-center justify-between p-4 sm:p-6 pb-3 sm:pb-4">
<div className="flex items-center gap-3 min-w-0 flex-1">
{icon && (
<div className="w-10 h-10 rounded-full bg-[var(--color-primary)]/10 flex items-center justify-center text-[var(--color-primary)]">
<div className="w-10 h-10 sm:w-12 sm:h-12 flex-shrink-0 rounded-xl bg-gradient-to-br from-[var(--color-primary)]/10 to-[var(--color-primary)]/5 flex items-center justify-center text-[var(--color-primary)] shadow-sm">
{icon}
</div>
)}
<div>
<h2 className="text-xl font-semibold text-[var(--text-primary)]">
<div className="min-w-0 flex-1">
<h2 className="text-lg sm:text-xl font-bold text-[var(--text-primary)] truncate">
{title}
</h2>
<p className="text-sm text-[var(--text-secondary)] mt-0.5">
{currentStep.description || `Step ${currentStepIndex + 1} of ${steps.length}`}
<p className="text-xs sm:text-sm text-[var(--text-secondary)] mt-0.5 truncate">
{currentStep.description || `Paso ${currentStepIndex + 1} de ${steps.length}`}
</p>
</div>
</div>
<button
onClick={handleClose}
className="p-2 text-[var(--text-secondary)] hover:text-[var(--text-primary)] hover:bg-[var(--bg-secondary)] rounded-lg transition-colors"
className="p-2 flex-shrink-0 text-[var(--text-secondary)] hover:text-[var(--text-primary)] hover:bg-[var(--bg-secondary)] rounded-lg transition-all hover:scale-110 active:scale-95"
aria-label="Cerrar"
>
<X className="w-5 h-5" />
</button>
</div>
{/* Progress Bar */}
<div className="px-6 pb-4">
<div className="flex items-center gap-2 mb-2">
{steps.map((step, index) => (
<React.Fragment key={step.id}>
<button
onClick={() => index < currentStepIndex && goToStep(index)}
disabled={index > currentStepIndex}
className={`flex-1 h-2 rounded-full transition-all duration-300 ${
index < currentStepIndex
? 'bg-[var(--color-success)] cursor-pointer hover:bg-[var(--color-success)]/80'
: index === currentStepIndex
? 'bg-[var(--color-primary)]'
: 'bg-[var(--bg-tertiary)] cursor-not-allowed'
}`}
title={step.title}
/>
</React.Fragment>
))}
{/* Enhanced Progress Bar */}
<div className="px-4 sm:px-6 pb-4">
<div className="flex items-center gap-1.5 sm:gap-2 mb-2.5">
{steps.map((step, index) => {
const isCompleted = index < currentStepIndex;
const isCurrent = index === currentStepIndex;
const isUpcoming = index > currentStepIndex;
return (
<div
key={step.id}
className="flex-1 group relative"
>
<button
onClick={() => isCompleted && goToStep(index)}
disabled={!isCompleted}
className={`w-full h-2.5 rounded-full transition-all duration-300 relative overflow-hidden ${
isCompleted
? 'bg-[var(--color-success)] cursor-pointer hover:bg-[var(--color-success)]/80 hover:h-3'
: isCurrent
? 'bg-[var(--color-primary)] shadow-md'
: 'bg-[var(--bg-tertiary)] cursor-not-allowed'
}`}
aria-label={`${step.title} - ${isCompleted ? 'Completado' : isCurrent ? 'En progreso' : 'Pendiente'}`}
>
{isCurrent && (
<div className="absolute inset-0 bg-gradient-to-r from-transparent via-white/30 to-transparent animate-shimmer" />
)}
</button>
{/* Tooltip on hover */}
<div className="absolute left-1/2 -translate-x-1/2 bottom-full mb-2 px-2 py-1 bg-[var(--bg-primary)] border border-[var(--border-secondary)] rounded shadow-lg text-xs whitespace-nowrap opacity-0 group-hover:opacity-100 transition-opacity pointer-events-none z-20">
{step.title}
<div className="absolute top-full left-1/2 -translate-x-1/2 -mt-px border-4 border-transparent border-t-[var(--border-secondary)]" />
</div>
</div>
);
})}
</div>
<div className="flex items-center justify-between text-xs text-[var(--text-secondary)]">
<span className="font-medium">{currentStep.title}</span>
<span>{currentStepIndex + 1} / {steps.length}</span>
<div className="flex items-center justify-between text-xs sm:text-sm">
<div className="flex items-center gap-2 min-w-0 flex-1">
<span className="font-semibold text-[var(--text-primary)] truncate">{currentStep.title}</span>
{currentStep.isOptional && (
<span className="px-2 py-0.5 text-xs bg-[var(--bg-secondary)] text-[var(--text-tertiary)] rounded-full flex-shrink-0">
Opcional
</span>
)}
</div>
<span className="text-[var(--text-tertiary)] font-medium ml-2 flex-shrink-0">
{currentStepIndex + 1} / {steps.length}
</span>
</div>
</div>
</div>
{/* Step Content */}
<div className="p-6 overflow-y-auto" style={{ maxHeight: 'calc(90vh - 220px)' }}>
<div className="p-4 sm:p-6 overflow-y-auto" style={{ maxHeight: 'calc(90vh - 220px)' }}>
{/* Validation Messages */}
{validationError && (
<div className="mb-4 p-3 sm:p-4 bg-red-50 border border-red-200 rounded-lg flex items-start gap-3 animate-slideDown">
<AlertCircle className="w-5 h-5 text-red-600 flex-shrink-0 mt-0.5" />
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-red-800">{validationError}</p>
</div>
<button
onClick={() => setValidationError(null)}
className="flex-shrink-0 text-red-400 hover:text-red-600 transition-colors"
>
<X className="w-4 h-4" />
</button>
</div>
)}
{validationSuccess && (
<div className="mb-4 p-3 sm:p-4 bg-green-50 border border-green-200 rounded-lg flex items-center gap-3 animate-slideDown">
<CheckCircle className="w-5 h-5 text-green-600 flex-shrink-0" />
<p className="text-sm font-medium text-green-800">¡Validación exitosa!</p>
</div>
)}
<StepComponent
onNext={handleNext}
onBack={handleBack}
@@ -202,52 +304,75 @@ export const WizardModal: React.FC<WizardModalProps> = ({
</div>
{/* Footer Navigation */}
<div className="sticky bottom-0 border-t border-[var(--border-secondary)] bg-[var(--bg-secondary)]/50 backdrop-blur-sm px-6 py-4">
<div className="flex items-center justify-between gap-3">
{/* Back Button */}
<div className="sticky bottom-0 border-t border-[var(--border-secondary)] bg-[var(--bg-secondary)]/80 backdrop-blur-md px-4 sm:px-6 py-3 sm:py-4 shadow-lg">
{/* Keyboard Shortcuts Hint */}
<div className="hidden md:flex items-center justify-center gap-4 text-xs text-[var(--text-tertiary)] mb-2 pb-2 border-b border-[var(--border-secondary)]/50">
<span className="flex items-center gap-1.5">
<kbd className="px-1.5 py-0.5 bg-[var(--bg-primary)] border border-[var(--border-secondary)] rounded text-[10px] font-mono">ESC</kbd>
Cerrar
</span>
{!isFirstStep && (
<span className="flex items-center gap-1.5">
<kbd className="px-1.5 py-0.5 bg-[var(--bg-primary)] border border-[var(--border-secondary)] rounded text-[10px] font-mono"></kbd>
Atrás
</span>
)}
<span className="flex items-center gap-1.5">
<kbd className="px-1.5 py-0.5 bg-[var(--bg-primary)] border border-[var(--border-secondary)] rounded text-[10px] font-mono"></kbd>
<kbd className="px-1.5 py-0.5 bg-[var(--bg-primary)] border border-[var(--border-secondary)] rounded text-[10px] font-mono">ENTER</kbd>
{isLastStep ? 'Completar' : 'Siguiente'}
</span>
</div>
<div className="flex items-center justify-between gap-2 sm:gap-3">
{/* Back Button */}
{!isFirstStep ? (
<button
onClick={handleBack}
disabled={isValidating}
className="px-4 py-2.5 text-[var(--text-secondary)] hover:text-[var(--text-primary)] hover:bg-[var(--bg-primary)] rounded-lg transition-colors font-medium disabled:opacity-50 disabled:cursor-not-allowed inline-flex items-center gap-2"
className="px-3 sm:px-4 py-2.5 text-[var(--text-secondary)] hover:text-[var(--text-primary)] hover:bg-[var(--bg-primary)] rounded-lg transition-all font-medium disabled:opacity-50 disabled:cursor-not-allowed inline-flex items-center gap-1.5 sm:gap-2 active:scale-95"
>
<ChevronLeft className="w-4 h-4" />
Back
<span className="hidden sm:inline">Atrás</span>
</button>
) : (
<div />
)}
<div className="flex-1" />
{/* Skip Button (for optional steps) */}
{currentStep.isOptional && !isLastStep && (
<button
onClick={() => setCurrentStepIndex(prev => prev + 1)}
disabled={isValidating}
className="px-4 py-2.5 text-[var(--text-secondary)] hover:text-[var(--text-primary)] hover:bg-[var(--bg-primary)] rounded-lg transition-colors font-medium disabled:opacity-50"
className="px-3 sm:px-4 py-2.5 text-[var(--text-secondary)] hover:text-[var(--text-primary)] hover:bg-[var(--bg-primary)] rounded-lg transition-all font-medium disabled:opacity-50 text-sm active:scale-95"
>
Skip This Step
Saltar
</button>
)}
{/* Spacer */}
<div className="flex-1" />
{/* Next/Complete Button */}
<button
onClick={handleNext}
disabled={isValidating}
className="px-6 py-2.5 bg-[var(--color-primary)] text-white rounded-lg hover:bg-[var(--color-primary-dark)] disabled:opacity-50 disabled:cursor-not-allowed transition-colors font-medium inline-flex items-center gap-2 min-w-[140px] justify-center"
className="px-4 sm:px-6 py-2.5 bg-[var(--color-primary)] text-white rounded-lg hover:bg-[var(--color-primary-dark)] disabled:opacity-50 disabled:cursor-not-allowed transition-all font-semibold inline-flex items-center gap-2 min-w-[100px] sm:min-w-[140px] justify-center shadow-md hover:shadow-lg active:scale-95"
>
{isValidating ? (
<>
<div className="w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin" />
Validating...
<span className="hidden sm:inline">Validando...</span>
</>
) : isLastStep ? (
<>
Complete
Completar
<ChevronRight className="w-4 h-4" />
</>
) : (
<>
Next
<span className="hidden sm:inline">Siguiente</span>
<span className="sm:hidden">Sig.</span>
<ChevronRight className="w-4 h-4" />
</>
)}
@@ -273,12 +398,36 @@ export const WizardModal: React.FC<WizardModalProps> = ({
transform: translateY(0);
}
}
@keyframes shimmer {
0% {
transform: translateX(-100%);
}
100% {
transform: translateX(100%);
}
}
@keyframes slideDown {
from {
opacity: 0;
transform: translateY(-10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.animate-fadeIn {
animation: fadeIn 0.2s ease-out;
}
.animate-slideUp {
animation: slideUp 0.3s ease-out;
}
.animate-slideDown {
animation: slideDown 0.3s ease-out;
}
.animate-shimmer {
animation: shimmer 2s infinite;
}
`}</style>
</>
);