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>
</>
);

View File

@@ -27,6 +27,8 @@ function MyComponent() {
-**Conversion CTAs** throughout experience
-**Responsive design** across all devices
-**Accessibility** (ARIA, keyboard navigation)
-**Demo banner** with session status and time remaining countdown
-**Exit modal** with benefits reminder and conversion messaging
## Project Structure
@@ -91,9 +93,9 @@ Track tour analytics event.
### Desktop (12 steps)
1. Welcome to Demo Session
2. Real-time Metrics Dashboard
3. Intelligent Alerts
4. Procurement Plans
5. Production Management
3. Pending Approvals
4. System Actions Log
5. Daily Production Plan
6. Database Navigation (Sidebar)
7. Daily Operations (Sidebar)
8. Analytics & AI (Sidebar)
@@ -151,9 +153,9 @@ The tour targets elements with `data-tour` attributes:
- `demo-banner` - Demo banner
- `demo-banner-actions` - Banner action buttons
- `dashboard-stats` - Metrics grid
- `real-time-alerts` - Alerts section
- `procurement-plans` - Procurement section
- `production-plans` - Production section
- `pending-po-approvals` - Approval requests
- `real-time-alerts` - System actions log
- `today-production` - Daily production plan
- `sidebar-database` - Database navigation
- `sidebar-operations` - Operations navigation
- `sidebar-analytics` - Analytics navigation