Update readmes and imporve UI
This commit is contained in:
@@ -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;
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user