Support multiple languages
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
import React, { useState, useRef, useEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Button, Input, Card } from '../../ui';
|
||||
import { useAuthActions, useAuthLoading, useAuthError } from '../../../stores/auth.store';
|
||||
import { useToast } from '../../../hooks/ui/useToast';
|
||||
@@ -24,6 +25,7 @@ export const LoginForm: React.FC<LoginFormProps> = ({
|
||||
className,
|
||||
autoFocus = true
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const [credentials, setCredentials] = useState<LoginCredentials>({
|
||||
email: '',
|
||||
password: '',
|
||||
@@ -32,7 +34,7 @@ export const LoginForm: React.FC<LoginFormProps> = ({
|
||||
const [errors, setErrors] = useState<Partial<LoginCredentials>>({});
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const emailInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
|
||||
const { login } = useAuthActions();
|
||||
const isLoading = useAuthLoading();
|
||||
const error = useAuthError();
|
||||
@@ -49,13 +51,13 @@ export const LoginForm: React.FC<LoginFormProps> = ({
|
||||
const newErrors: Partial<LoginCredentials> = {};
|
||||
|
||||
if (!credentials.email.trim()) {
|
||||
newErrors.email = 'El email es requerido';
|
||||
newErrors.email = t('auth:validation.email_required', 'El email es requerido');
|
||||
} else if (!/^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/.test(credentials.email)) {
|
||||
newErrors.email = 'Por favor, ingrese un email válido';
|
||||
newErrors.email = t('auth:validation.email_invalid', 'Por favor, ingrese un email válido');
|
||||
}
|
||||
|
||||
if (!credentials.password) {
|
||||
newErrors.password = 'La contraseña es requerida';
|
||||
newErrors.password = t('auth:validation.password_required', 'La contraseña es requerida');
|
||||
}
|
||||
|
||||
setErrors(newErrors);
|
||||
@@ -114,10 +116,10 @@ export const LoginForm: React.FC<LoginFormProps> = ({
|
||||
<Card className={`p-8 w-full max-w-md ${className || ''}`} role="main">
|
||||
<div className="text-center mb-8">
|
||||
<h1 className="text-2xl font-bold text-text-primary mb-2">
|
||||
Iniciar Sesión
|
||||
{t('auth:login.title', 'Iniciar Sesión')}
|
||||
</h1>
|
||||
<p className="text-text-secondary">
|
||||
Accede al panel de control de tu panadería
|
||||
{t('auth:login.subtitle', 'Accede al panel de control de tu panadería')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -125,14 +127,14 @@ export const LoginForm: React.FC<LoginFormProps> = ({
|
||||
onSubmit={handleSubmit}
|
||||
className="space-y-6"
|
||||
noValidate
|
||||
aria-label="Formulario de inicio de sesión"
|
||||
aria-label={t('auth:login.title', 'Formulario de inicio de sesión')}
|
||||
onKeyDown={handleKeyDown}
|
||||
>
|
||||
<div>
|
||||
<Input
|
||||
ref={emailInputRef}
|
||||
type="email"
|
||||
label="Correo Electrónico"
|
||||
label={t('auth:login.email', 'Correo Electrónico')}
|
||||
placeholder="tu.email@panaderia.com"
|
||||
value={credentials.email}
|
||||
onChange={handleInputChange('email')}
|
||||
@@ -163,8 +165,8 @@ export const LoginForm: React.FC<LoginFormProps> = ({
|
||||
<div>
|
||||
<Input
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
label="Contraseña"
|
||||
placeholder="Tu contraseña segura"
|
||||
label={t('auth:login.password', 'Contraseña')}
|
||||
placeholder={t('auth:login.password', 'Tu contraseña segura')}
|
||||
value={credentials.password}
|
||||
onChange={handleInputChange('password')}
|
||||
error={errors.password}
|
||||
|
||||
@@ -3,7 +3,6 @@ import { Button, Input, Card, Select, Avatar, Modal } from '../../ui';
|
||||
import { useAuthUser } from '../../../stores/auth.store';
|
||||
import { useToast } from '../../../hooks/ui/useToast';
|
||||
import { useUpdateProfile, useChangePassword, useAuthProfile } from '../../../api/hooks/auth';
|
||||
import { useEffect } from 'react';
|
||||
|
||||
interface ProfileSettingsProps {
|
||||
onSuccess?: () => void;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Button, Input, Card } from '../../ui';
|
||||
import { PasswordCriteria, validatePassword, getPasswordErrors } from '../../ui/PasswordCriteria';
|
||||
import { useAuthActions, useAuthLoading, useAuthError } from '../../../stores/auth.store';
|
||||
@@ -23,6 +24,7 @@ export const RegisterForm: React.FC<RegisterFormProps> = ({
|
||||
onLoginClick,
|
||||
className
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const [formData, setFormData] = useState<SimpleUserRegistration>({
|
||||
full_name: '',
|
||||
email: '',
|
||||
@@ -53,19 +55,19 @@ export const RegisterForm: React.FC<RegisterFormProps> = ({
|
||||
const newErrors: Partial<SimpleUserRegistration> = {};
|
||||
|
||||
if (!formData.full_name.trim()) {
|
||||
newErrors.full_name = 'El nombre completo es requerido';
|
||||
newErrors.full_name = t('auth:validation.first_name_required', 'El nombre completo es requerido');
|
||||
} else if (formData.full_name.trim().length < 2) {
|
||||
newErrors.full_name = 'El nombre debe tener al menos 2 caracteres';
|
||||
newErrors.full_name = t('auth:validation.field_required', 'El nombre debe tener al menos 2 caracteres');
|
||||
}
|
||||
|
||||
if (!formData.email.trim()) {
|
||||
newErrors.email = 'El email es requerido';
|
||||
newErrors.email = t('auth:validation.email_required', 'El email es requerido');
|
||||
} else if (!/^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/.test(formData.email)) {
|
||||
newErrors.email = 'Por favor, ingrese un email válido';
|
||||
newErrors.email = t('auth:validation.email_invalid', 'Por favor, ingrese un email válido');
|
||||
}
|
||||
|
||||
if (!formData.password) {
|
||||
newErrors.password = 'La contraseña es requerida';
|
||||
newErrors.password = t('auth:validation.password_required', 'La contraseña es requerida');
|
||||
} else {
|
||||
const passwordErrors = getPasswordErrors(formData.password);
|
||||
if (passwordErrors.length > 0) {
|
||||
@@ -74,13 +76,13 @@ export const RegisterForm: React.FC<RegisterFormProps> = ({
|
||||
}
|
||||
|
||||
if (!formData.confirmPassword) {
|
||||
newErrors.confirmPassword = 'Confirma tu contraseña';
|
||||
newErrors.confirmPassword = t('auth:register.confirm_password', 'Confirma tu contraseña');
|
||||
} else if (formData.password !== formData.confirmPassword) {
|
||||
newErrors.confirmPassword = 'Las contraseñas no coinciden';
|
||||
newErrors.confirmPassword = t('auth:validation.passwords_must_match', 'Las contraseñas no coinciden');
|
||||
}
|
||||
|
||||
if (!formData.acceptTerms) {
|
||||
newErrors.acceptTerms = 'Debes aceptar los términos y condiciones';
|
||||
newErrors.acceptTerms = t('auth:validation.terms_required', 'Debes aceptar los términos y condiciones');
|
||||
}
|
||||
|
||||
setErrors(newErrors);
|
||||
@@ -104,13 +106,13 @@ export const RegisterForm: React.FC<RegisterFormProps> = ({
|
||||
|
||||
await register(registrationData);
|
||||
|
||||
showSuccessToast('¡Bienvenido! Tu cuenta ha sido creada correctamente.', {
|
||||
title: 'Cuenta creada exitosamente'
|
||||
showSuccessToast(t('auth:register.registering', '¡Bienvenido! Tu cuenta ha sido creada correctamente.'), {
|
||||
title: t('auth:alerts.success_create', 'Cuenta creada exitosamente')
|
||||
});
|
||||
onSuccess?.();
|
||||
} catch (err) {
|
||||
showErrorToast(error || 'No se pudo crear la cuenta. Verifica que el email no esté en uso.', {
|
||||
title: 'Error al crear la cuenta'
|
||||
showErrorToast(error || t('auth:register.register_button', 'No se pudo crear la cuenta. Verifica que el email no esté en uso.'), {
|
||||
title: t('auth:alerts.error_create', 'Error al crear la cuenta')
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -127,16 +129,16 @@ export const RegisterForm: React.FC<RegisterFormProps> = ({
|
||||
<Card className={`p-8 w-full max-w-md ${className || ''}`} role="main">
|
||||
<div className="text-center mb-8">
|
||||
<h1 className="text-3xl font-bold text-text-primary mb-2">
|
||||
Crear Cuenta
|
||||
{t('auth:register.title', 'Crear Cuenta')}
|
||||
</h1>
|
||||
<p className="text-text-secondary text-lg">
|
||||
Únete y comienza hoy mismo
|
||||
{t('auth:register.subtitle', 'Únete y comienza hoy mismo')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
<Input
|
||||
label="Nombre Completo"
|
||||
label={t('auth:register.first_name', 'Nombre Completo')}
|
||||
placeholder="Juan Pérez García"
|
||||
value={formData.full_name}
|
||||
onChange={handleInputChange('full_name')}
|
||||
@@ -153,7 +155,7 @@ export const RegisterForm: React.FC<RegisterFormProps> = ({
|
||||
|
||||
<Input
|
||||
type="email"
|
||||
label="Correo Electrónico"
|
||||
label={t('auth:register.email', 'Correo Electrónico')}
|
||||
placeholder="tu.email@ejemplo.com"
|
||||
value={formData.email}
|
||||
onChange={handleInputChange('email')}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Card, CardHeader, CardBody } from '../../ui/Card';
|
||||
import { Badge } from '../../ui/Badge';
|
||||
import { Button } from '../../ui/Button';
|
||||
@@ -41,6 +42,7 @@ const RealTimeAlerts: React.FC<RealTimeAlertsProps> = ({
|
||||
className,
|
||||
maxAlerts = 10
|
||||
}) => {
|
||||
const { t } = useTranslation(['dashboard']);
|
||||
const [expandedAlert, setExpandedAlert] = useState<string | null>(null);
|
||||
|
||||
const { notifications, isConnected, markAsRead, removeNotification } = useNotifications();
|
||||
@@ -108,11 +110,13 @@ const RealTimeAlerts: React.FC<RealTimeAlertsProps> = ({
|
||||
const diffMs = now.getTime() - date.getTime();
|
||||
const diffMins = Math.floor(diffMs / (1000 * 60));
|
||||
|
||||
if (diffMins < 1) return 'Ahora';
|
||||
if (diffMins < 60) return `${diffMins}m`;
|
||||
if (diffMins < 1) return t('dashboard:alerts.time.now', 'Ahora');
|
||||
if (diffMins < 60) return t('dashboard:alerts.time.minutes_ago', 'hace {{count}} min', { count: diffMins });
|
||||
const diffHours = Math.floor(diffMins / 60);
|
||||
if (diffHours < 24) return `${diffHours}h`;
|
||||
return date.toLocaleDateString('es-ES', { day: '2-digit', month: '2-digit' });
|
||||
if (diffHours < 24) return t('dashboard:alerts.time.hours_ago', 'hace {{count}} h', { count: diffHours });
|
||||
return date.toLocaleDateString() === new Date(now.getTime() - 24 * 60 * 60 * 1000).toLocaleDateString()
|
||||
? t('dashboard:alerts.time.yesterday', 'Ayer')
|
||||
: date.toLocaleDateString('es-ES', { day: '2-digit', month: '2-digit' });
|
||||
};
|
||||
|
||||
const toggleExpanded = (alertId: string) => {
|
||||
@@ -147,7 +151,7 @@ const RealTimeAlerts: React.FC<RealTimeAlertsProps> = ({
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-base font-semibold" style={{ color: 'var(--text-primary)' }}>
|
||||
Alertas
|
||||
{t('dashboard:alerts.title', 'Alertas')}
|
||||
</h3>
|
||||
<div className="flex items-center gap-2">
|
||||
{isConnected ? (
|
||||
@@ -156,7 +160,10 @@ const RealTimeAlerts: React.FC<RealTimeAlertsProps> = ({
|
||||
<WifiOff className="w-3 h-3" style={{ color: 'var(--color-error)' }} />
|
||||
)}
|
||||
<span className="text-xs" style={{ color: 'var(--text-secondary)' }}>
|
||||
{isConnected ? 'En vivo' : 'Desconectado'}
|
||||
{isConnected
|
||||
? t('dashboard:alerts.live', 'En vivo')
|
||||
: t('dashboard:alerts.offline', 'Desconectado')
|
||||
}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -182,7 +189,7 @@ const RealTimeAlerts: React.FC<RealTimeAlertsProps> = ({
|
||||
<div className="p-6 text-center">
|
||||
<CheckCircle className="w-6 h-6 mx-auto mb-2" style={{ color: 'var(--color-success)' }} />
|
||||
<p className="text-sm" style={{ color: 'var(--text-secondary)' }}>
|
||||
No hay alertas activas
|
||||
{t('dashboard:alerts.no_alerts', 'No hay alertas activas')}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
@@ -241,7 +248,10 @@ const RealTimeAlerts: React.FC<RealTimeAlertsProps> = ({
|
||||
{alert.severity.toUpperCase()}
|
||||
</Badge>
|
||||
<Badge variant="secondary" size="sm">
|
||||
{alert.item_type === 'alert' ? '🚨 Alerta' : '💡 Recomendación'}
|
||||
{alert.item_type === 'alert'
|
||||
? `🚨 ${t('dashboard:alerts.types.alert', 'Alerta')}`
|
||||
: `💡 ${t('dashboard:alerts.types.recommendation', 'Recomendación')}`
|
||||
}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
@@ -277,7 +287,7 @@ const RealTimeAlerts: React.FC<RealTimeAlertsProps> = ({
|
||||
{alert.actions && alert.actions.length > 0 && (
|
||||
<div className="mb-4">
|
||||
<p className="text-xs font-semibold mb-2 uppercase tracking-wide" style={{ color: 'var(--text-primary)' }}>
|
||||
Acciones Recomendadas
|
||||
{t('dashboard:alerts.recommended_actions', 'Acciones Recomendadas')}
|
||||
</p>
|
||||
<div className="space-y-1">
|
||||
{alert.actions.map((action, index) => (
|
||||
@@ -298,7 +308,7 @@ const RealTimeAlerts: React.FC<RealTimeAlertsProps> = ({
|
||||
{alert.metadata && Object.keys(alert.metadata).length > 0 && (
|
||||
<div className="mb-4 p-2 rounded-md" style={{ backgroundColor: 'var(--bg-secondary)' }}>
|
||||
<p className="text-xs font-semibold mb-1 uppercase tracking-wide" style={{ color: 'var(--text-primary)' }}>
|
||||
Detalles Adicionales
|
||||
{t('dashboard:alerts.additional_details', 'Detalles Adicionales')}
|
||||
</p>
|
||||
<div className="text-xs space-y-1" style={{ color: 'var(--text-secondary)' }}>
|
||||
{Object.entries(alert.metadata).map(([key, value]) => (
|
||||
@@ -323,7 +333,7 @@ const RealTimeAlerts: React.FC<RealTimeAlertsProps> = ({
|
||||
className="h-8 px-3 text-xs font-medium"
|
||||
>
|
||||
<Check className="w-3 h-3 mr-1" />
|
||||
Marcar como leído
|
||||
{t('dashboard:alerts.mark_as_read', 'Marcar como leído')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
@@ -335,7 +345,7 @@ const RealTimeAlerts: React.FC<RealTimeAlertsProps> = ({
|
||||
className="h-8 px-3 text-xs font-medium text-red-600 hover:text-red-700 hover:bg-red-50"
|
||||
>
|
||||
<Trash2 className="w-3 h-3 mr-1" />
|
||||
Eliminar
|
||||
{t('dashboard:alerts.remove', 'Eliminar')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -355,7 +365,7 @@ const RealTimeAlerts: React.FC<RealTimeAlertsProps> = ({
|
||||
}}
|
||||
>
|
||||
<p className="text-xs" style={{ color: 'var(--text-secondary)' }}>
|
||||
{activeAlerts.length} alertas activas
|
||||
{t('dashboard:alerts.active_count', '{{count}} alertas activas', { count: activeAlerts.length })}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Plus, Package, Calculator, Settings } from 'lucide-react';
|
||||
import { StatusModal } from '../../ui/StatusModal/StatusModal';
|
||||
import { IngredientCreate, UnitOfMeasure, IngredientCategory, ProductCategory } from '../../../api/types/inventory';
|
||||
@@ -20,6 +21,7 @@ export const CreateIngredientModal: React.FC<CreateIngredientModalProps> = ({
|
||||
onClose,
|
||||
onCreateIngredient
|
||||
}) => {
|
||||
const { t } = useTranslation(['inventory']);
|
||||
const [formData, setFormData] = useState<IngredientCreate>({
|
||||
name: '',
|
||||
description: '',
|
||||
@@ -70,32 +72,32 @@ export const CreateIngredientModal: React.FC<CreateIngredientModalProps> = ({
|
||||
const handleSave = async () => {
|
||||
// Validation
|
||||
if (!formData.name?.trim()) {
|
||||
alert('El nombre es requerido');
|
||||
alert(t('inventory:validation.name_required', 'El nombre es requerido'));
|
||||
return;
|
||||
}
|
||||
|
||||
if (!formData.category) {
|
||||
alert('La categoría es requerida');
|
||||
alert(t('inventory:validation.category_required', 'La categoría es requerida'));
|
||||
return;
|
||||
}
|
||||
|
||||
if (!formData.unit_of_measure) {
|
||||
alert('La unidad de medida es requerida');
|
||||
alert(t('inventory:validation.unit_required', 'La unidad de medida es requerida'));
|
||||
return;
|
||||
}
|
||||
|
||||
if (!formData.low_stock_threshold || formData.low_stock_threshold < 0) {
|
||||
alert('El umbral de stock bajo debe ser un número positivo');
|
||||
alert(t('inventory:validation.min_greater_than_zero', 'El umbral de stock bajo debe ser un número positivo'));
|
||||
return;
|
||||
}
|
||||
|
||||
if (!formData.reorder_point || formData.reorder_point < 0) {
|
||||
alert('El punto de reorden debe ser un número positivo');
|
||||
alert(t('inventory:validation.min_greater_than_zero', 'El punto de reorden debe ser un número positivo'));
|
||||
return;
|
||||
}
|
||||
|
||||
if (formData.reorder_point <= formData.low_stock_threshold) {
|
||||
alert('El punto de reorden debe ser mayor que el umbral de stock bajo');
|
||||
alert(t('inventory:validation.max_greater_than_min', 'El punto de reorden debe ser mayor que el umbral de stock bajo'));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -153,7 +155,7 @@ export const CreateIngredientModal: React.FC<CreateIngredientModalProps> = ({
|
||||
|
||||
const statusConfig = {
|
||||
color: statusColors.inProgress.primary,
|
||||
text: 'Nuevo Artículo',
|
||||
text: t('inventory:actions.add_item', 'Nuevo Artículo'),
|
||||
icon: Plus,
|
||||
isCritical: false,
|
||||
isHighlight: true
|
||||
@@ -161,11 +163,11 @@ export const CreateIngredientModal: React.FC<CreateIngredientModalProps> = ({
|
||||
|
||||
const sections = [
|
||||
{
|
||||
title: 'Información Básica',
|
||||
title: t('inventory:forms.item_details', 'Información Básica'),
|
||||
icon: Package,
|
||||
fields: [
|
||||
{
|
||||
label: 'Nombre',
|
||||
label: t('inventory:fields.name', 'Nombre'),
|
||||
value: formData.name,
|
||||
type: 'text' as const,
|
||||
editable: true,
|
||||
@@ -173,7 +175,7 @@ export const CreateIngredientModal: React.FC<CreateIngredientModalProps> = ({
|
||||
placeholder: 'Ej: Harina de trigo 000'
|
||||
},
|
||||
{
|
||||
label: 'Descripción',
|
||||
label: t('inventory:fields.description', 'Descripción'),
|
||||
value: formData.description || '',
|
||||
type: 'text' as const,
|
||||
editable: true,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Button } from '../../ui/Button';
|
||||
import { Card, CardHeader, CardBody } from '../../ui/Card';
|
||||
import { useAuth } from '../../../contexts/AuthContext';
|
||||
@@ -29,40 +30,41 @@ interface StepProps {
|
||||
isLastStep: boolean;
|
||||
}
|
||||
|
||||
// Steps must match backend ONBOARDING_STEPS exactly
|
||||
// Note: "user_registered" is auto-completed and not shown in UI
|
||||
const STEPS: StepConfig[] = [
|
||||
{
|
||||
id: 'setup',
|
||||
title: 'Registrar Panadería',
|
||||
description: 'Configura la información básica de tu panadería',
|
||||
component: RegisterTenantStep,
|
||||
},
|
||||
{
|
||||
id: 'smart-inventory-setup',
|
||||
title: 'Configurar Inventario',
|
||||
description: 'Sube datos de ventas y configura tu inventario inicial',
|
||||
component: UploadSalesDataStep,
|
||||
},
|
||||
{
|
||||
id: 'ml-training',
|
||||
title: 'Entrenamiento IA',
|
||||
description: 'Entrena tu modelo de inteligencia artificial personalizado',
|
||||
component: MLTrainingStep,
|
||||
},
|
||||
{
|
||||
id: 'completion',
|
||||
title: 'Configuración Completa',
|
||||
description: '¡Bienvenido a tu sistema de gestión inteligente!',
|
||||
component: CompletionStep,
|
||||
},
|
||||
];
|
||||
|
||||
export const OnboardingWizard: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const [searchParams] = useSearchParams();
|
||||
const navigate = useNavigate();
|
||||
const { user } = useAuth();
|
||||
|
||||
// Steps must match backend ONBOARDING_STEPS exactly
|
||||
// Note: "user_registered" is auto-completed and not shown in UI
|
||||
const STEPS: StepConfig[] = [
|
||||
{
|
||||
id: 'setup',
|
||||
title: t('onboarding:wizard.steps.setup.title', 'Registrar Panadería'),
|
||||
description: t('onboarding:wizard.steps.setup.description', 'Configura la información básica de tu panadería'),
|
||||
component: RegisterTenantStep,
|
||||
},
|
||||
{
|
||||
id: 'smart-inventory-setup',
|
||||
title: t('onboarding:wizard.steps.smart_inventory_setup.title', 'Configurar Inventario'),
|
||||
description: t('onboarding:wizard.steps.smart_inventory_setup.description', 'Sube datos de ventas y configura tu inventario inicial'),
|
||||
component: UploadSalesDataStep,
|
||||
},
|
||||
{
|
||||
id: 'ml-training',
|
||||
title: t('onboarding:wizard.steps.ml_training.title', 'Entrenamiento IA'),
|
||||
description: t('onboarding:wizard.steps.ml_training.description', 'Entrena tu modelo de inteligencia artificial personalizado'),
|
||||
component: MLTrainingStep,
|
||||
},
|
||||
{
|
||||
id: 'completion',
|
||||
title: t('onboarding:wizard.steps.completion.title', 'Configuración Completa'),
|
||||
description: t('onboarding:wizard.steps.completion.description', '¡Bienvenido a tu sistema de gestión inteligente!'),
|
||||
component: CompletionStep,
|
||||
},
|
||||
];
|
||||
|
||||
// Check if this is a fresh onboarding (new tenant creation)
|
||||
const isNewTenant = searchParams.get('new') === 'true';
|
||||
|
||||
@@ -277,7 +279,7 @@ export const OnboardingWizard: React.FC = () => {
|
||||
}
|
||||
|
||||
// Don't advance automatically on error - user should see the issue
|
||||
alert(`Error al completar paso "${currentStep.title}": ${errorMessage}`);
|
||||
alert(`${t('onboarding:errors.step_failed', 'Error al completar paso')} "${currentStep.title}": ${errorMessage}`);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -289,7 +291,7 @@ export const OnboardingWizard: React.FC = () => {
|
||||
<CardBody>
|
||||
<div className="flex items-center justify-center space-x-3">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-[var(--color-primary)]"></div>
|
||||
<p className="text-[var(--text-secondary)] text-sm sm:text-base">Cargando tu progreso...</p>
|
||||
<p className="text-[var(--text-secondary)] text-sm sm:text-base">{t('common:loading', 'Cargando tu progreso...')}</p>
|
||||
</div>
|
||||
</CardBody>
|
||||
</Card>
|
||||
@@ -311,17 +313,17 @@ export const OnboardingWizard: React.FC = () => {
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-base sm:text-lg font-semibold text-[var(--text-primary)] mb-2">
|
||||
Error al cargar progreso
|
||||
{t('onboarding:errors.network_error', 'Error al cargar progreso')}
|
||||
</h2>
|
||||
<p className="text-sm sm:text-base text-[var(--text-secondary)] mb-4 px-2">
|
||||
No pudimos cargar tu progreso de configuración. Puedes continuar desde el inicio.
|
||||
{t('onboarding:errors.try_again', 'No pudimos cargar tu progreso de configuración. Puedes continuar desde el inicio.')}
|
||||
</p>
|
||||
<Button
|
||||
onClick={() => setIsInitialized(true)}
|
||||
variant="primary"
|
||||
size="lg"
|
||||
>
|
||||
Continuar
|
||||
{t('onboarding:wizard.navigation.next', 'Continuar')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -350,10 +352,10 @@ export const OnboardingWizard: React.FC = () => {
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-[var(--text-primary)]">
|
||||
Creando Nueva Organización
|
||||
{t('onboarding:wizard.title', 'Creando Nueva Organización')}
|
||||
</p>
|
||||
<p className="text-xs text-[var(--text-secondary)]">
|
||||
Configurarás una nueva panadería desde cero. Este proceso es independiente de tus organizaciones existentes.
|
||||
{t('onboarding:wizard.subtitle', 'Configurarás una nueva panadería desde cero. Este proceso es independiente de tus organizaciones existentes.')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -366,21 +368,21 @@ export const OnboardingWizard: React.FC = () => {
|
||||
<div className="flex flex-col sm:flex-row sm:justify-between sm:items-center mb-4 space-y-2 sm:space-y-0">
|
||||
<div className="text-center sm:text-left">
|
||||
<h1 className="text-xl sm:text-2xl font-bold text-[var(--text-primary)]">
|
||||
{isNewTenant ? 'Crear Nueva Organización' : 'Bienvenido a Bakery IA'}
|
||||
{isNewTenant ? t('onboarding:wizard.title', 'Crear Nueva Organización') : t('onboarding:wizard.title', 'Bienvenido a Bakery IA')}
|
||||
</h1>
|
||||
<p className="text-[var(--text-secondary)] text-xs sm:text-sm mt-1">
|
||||
{isNewTenant
|
||||
? 'Configura tu nueva panadería desde cero'
|
||||
: 'Configura tu sistema de gestión inteligente paso a paso'
|
||||
? t('onboarding:wizard.subtitle', 'Configura tu nueva panadería desde cero')
|
||||
: t('onboarding:wizard.subtitle', 'Configura tu sistema de gestión inteligente paso a paso')
|
||||
}
|
||||
</p>
|
||||
</div>
|
||||
<div className="text-center sm:text-right">
|
||||
<div className="text-sm text-[var(--text-secondary)]">
|
||||
Paso {currentStepIndex + 1} de {STEPS.length}
|
||||
{t('onboarding:wizard.progress.step_of', 'Paso {{current}} de {{total}}', { current: currentStepIndex + 1, total: STEPS.length })}
|
||||
</div>
|
||||
<div className="text-xs text-[var(--text-tertiary)]">
|
||||
{Math.round(progressPercentage)}% completado
|
||||
{Math.round(progressPercentage)}% {t('onboarding:wizard.progress.completed', 'completado')}
|
||||
{isNewTenant && <span className="text-[var(--color-primary)] ml-1">(nuevo)</span>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import React, { useState, useEffect, useMemo, useCallback } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
Table,
|
||||
Button,
|
||||
@@ -95,35 +96,7 @@ const StatusColors = {
|
||||
[OrderStatus.CANCELADO]: 'red'
|
||||
} as const;
|
||||
|
||||
const StatusLabels = {
|
||||
[OrderStatus.PENDIENTE]: 'Pendiente',
|
||||
[OrderStatus.CONFIRMADO]: 'Confirmado',
|
||||
[OrderStatus.EN_PREPARACION]: 'En Preparación',
|
||||
[OrderStatus.LISTO]: 'Listo para Entrega',
|
||||
[OrderStatus.ENTREGADO]: 'Entregado',
|
||||
[OrderStatus.CANCELADO]: 'Cancelado'
|
||||
} as const;
|
||||
|
||||
const ChannelLabels = {
|
||||
[SalesChannel.STORE_FRONT]: 'Tienda',
|
||||
[SalesChannel.ONLINE]: 'Online',
|
||||
[SalesChannel.PHONE_ORDER]: 'Teléfono',
|
||||
[SalesChannel.DELIVERY]: 'Delivery',
|
||||
[SalesChannel.CATERING]: 'Catering',
|
||||
[SalesChannel.WHOLESALE]: 'Mayorista',
|
||||
[SalesChannel.FARMERS_MARKET]: 'Mercado',
|
||||
[SalesChannel.THIRD_PARTY]: 'Terceros'
|
||||
} as const;
|
||||
|
||||
const PaymentLabels = {
|
||||
[PaymentMethod.CASH]: 'Efectivo',
|
||||
[PaymentMethod.CREDIT_CARD]: 'Tarjeta Crédito',
|
||||
[PaymentMethod.DEBIT_CARD]: 'Tarjeta Débito',
|
||||
[PaymentMethod.DIGITAL_WALLET]: 'Wallet Digital',
|
||||
[PaymentMethod.BANK_TRANSFER]: 'Transferencia',
|
||||
[PaymentMethod.CHECK]: 'Cheque',
|
||||
[PaymentMethod.STORE_CREDIT]: 'Crédito Tienda'
|
||||
} as const;
|
||||
// Note: These will be replaced with translation functions inside the component
|
||||
|
||||
export const OrdersTable: React.FC<OrdersTableProps> = ({
|
||||
tenantId,
|
||||
@@ -132,6 +105,34 @@ export const OrdersTable: React.FC<OrdersTableProps> = ({
|
||||
onOrderUpdate,
|
||||
initialFilters = {}
|
||||
}) => {
|
||||
const { t } = useTranslation(['sales']);
|
||||
|
||||
// Translation helper functions
|
||||
const getStatusLabel = (status: OrderStatus) => {
|
||||
const statusKey = status.toLowerCase();
|
||||
return t(`sales:orders.status.${statusKey}`, StatusLabels[status] || status);
|
||||
};
|
||||
|
||||
const getChannelLabel = (channel: SalesChannel) => {
|
||||
const channelKey = channel.toLowerCase();
|
||||
return t(`sales:orders.channels.${channelKey}`, channel);
|
||||
};
|
||||
|
||||
const getPaymentLabel = (method: PaymentMethod) => {
|
||||
const methodKey = method.toLowerCase();
|
||||
return t(`sales:orders.payment_methods.${methodKey}`, method);
|
||||
};
|
||||
|
||||
// Legacy objects for fallbacks (will be removed after migration)
|
||||
const StatusLabels = {
|
||||
[OrderStatus.PENDIENTE]: 'Pendiente',
|
||||
[OrderStatus.CONFIRMADO]: 'Confirmado',
|
||||
[OrderStatus.EN_PREPARACION]: 'En Preparación',
|
||||
[OrderStatus.LISTO]: 'Listo para Entrega',
|
||||
[OrderStatus.ENTREGADO]: 'Entregado',
|
||||
[OrderStatus.CANCELADO]: 'Cancelado'
|
||||
} as const;
|
||||
|
||||
// State
|
||||
const [orders, setOrders] = useState<Order[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
@@ -270,7 +271,7 @@ export const OrdersTable: React.FC<OrdersTableProps> = ({
|
||||
},
|
||||
{
|
||||
key: 'id',
|
||||
title: 'Nº Pedido',
|
||||
title: t('sales:orders.table.columns.order_number', 'Nº Pedido'),
|
||||
sortable: true,
|
||||
render: (order: Order) => (
|
||||
<button
|
||||
@@ -286,7 +287,7 @@ export const OrdersTable: React.FC<OrdersTableProps> = ({
|
||||
},
|
||||
{
|
||||
key: 'customer_name',
|
||||
title: 'Cliente',
|
||||
title: t('sales:orders.table.columns.customer', 'Cliente'),
|
||||
sortable: true,
|
||||
render: (order: Order) => (
|
||||
<div>
|
||||
@@ -299,7 +300,7 @@ export const OrdersTable: React.FC<OrdersTableProps> = ({
|
||||
},
|
||||
{
|
||||
key: 'products',
|
||||
title: 'Productos',
|
||||
title: t('sales:orders.table.columns.products', 'Productos'),
|
||||
render: (order: Order) => (
|
||||
<div>
|
||||
<div className="font-medium">{order.product_name}</div>
|
||||
@@ -311,7 +312,7 @@ export const OrdersTable: React.FC<OrdersTableProps> = ({
|
||||
},
|
||||
{
|
||||
key: 'total_revenue',
|
||||
title: 'Total',
|
||||
title: t('sales:orders.table.columns.total', 'Total'),
|
||||
sortable: true,
|
||||
render: (order: Order) => (
|
||||
<div className="text-right">
|
||||
@@ -326,17 +327,17 @@ export const OrdersTable: React.FC<OrdersTableProps> = ({
|
||||
},
|
||||
{
|
||||
key: 'status',
|
||||
title: 'Estado',
|
||||
title: t('sales:orders.table.columns.status', 'Estado'),
|
||||
sortable: true,
|
||||
render: (order: Order) => (
|
||||
<Badge color={StatusColors[order.status]} variant="soft">
|
||||
{StatusLabels[order.status]}
|
||||
{getStatusLabel(order.status)}
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'sales_channel',
|
||||
title: 'Canal',
|
||||
title: t('sales:orders.table.columns.channel', 'Canal'),
|
||||
render: (order: Order) => (
|
||||
<span className="text-sm text-[var(--text-secondary)]">
|
||||
{ChannelLabels[order.sales_channel]}
|
||||
@@ -345,7 +346,7 @@ export const OrdersTable: React.FC<OrdersTableProps> = ({
|
||||
},
|
||||
{
|
||||
key: 'date',
|
||||
title: 'Fecha',
|
||||
title: t('sales:orders.table.columns.date', 'Fecha'),
|
||||
sortable: true,
|
||||
render: (order: Order) => (
|
||||
<div>
|
||||
@@ -364,7 +365,7 @@ export const OrdersTable: React.FC<OrdersTableProps> = ({
|
||||
if (showActions) {
|
||||
columns.push({
|
||||
key: 'actions',
|
||||
title: 'Acciones',
|
||||
title: t('sales:orders.table.columns.actions', 'Acciones'),
|
||||
render: (order: Order) => (
|
||||
<div className="flex space-x-2">
|
||||
<Tooltip content="Ver detalles">
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import React, { forwardRef } from 'react';
|
||||
import { clsx } from 'clsx';
|
||||
import { useLocation, Link } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { getBreadcrumbs, getRouteByPath } from '../../../router/routes.config';
|
||||
import {
|
||||
ChevronRight,
|
||||
@@ -94,7 +95,7 @@ export const Breadcrumbs = forwardRef<BreadcrumbsRef, BreadcrumbsProps>(({
|
||||
items: customItems,
|
||||
showHome = true,
|
||||
homePath = '/',
|
||||
homeLabel = 'Inicio',
|
||||
homeLabel,
|
||||
separator,
|
||||
maxItems = 5,
|
||||
truncateMiddle = true,
|
||||
@@ -103,9 +104,13 @@ export const Breadcrumbs = forwardRef<BreadcrumbsRef, BreadcrumbsProps>(({
|
||||
hiddenPaths = [],
|
||||
itemRenderer,
|
||||
}, ref) => {
|
||||
const { t } = useTranslation();
|
||||
const location = useLocation();
|
||||
const containerRef = React.useRef<HTMLDivElement>(null);
|
||||
|
||||
// Set default home label if not provided
|
||||
const resolvedHomeLabel = homeLabel || t('common:breadcrumbs.home', 'Inicio');
|
||||
|
||||
// Get breadcrumbs from router config or use custom items
|
||||
const routeBreadcrumbs = getBreadcrumbs(location.pathname);
|
||||
|
||||
@@ -115,11 +120,11 @@ export const Breadcrumbs = forwardRef<BreadcrumbsRef, BreadcrumbsProps>(({
|
||||
}
|
||||
|
||||
const items: BreadcrumbItem[] = [];
|
||||
|
||||
|
||||
// Add home if enabled
|
||||
if (showHome && location.pathname !== homePath) {
|
||||
items.push({
|
||||
label: homeLabel,
|
||||
label: resolvedHomeLabel,
|
||||
path: homePath,
|
||||
icon: Home,
|
||||
});
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import React, { forwardRef } from 'react';
|
||||
import { clsx } from 'clsx';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Button } from '../../ui';
|
||||
import {
|
||||
Heart,
|
||||
@@ -150,15 +151,16 @@ export const Footer = forwardRef<FooterRef, FooterProps>(({
|
||||
compact = false,
|
||||
children,
|
||||
}, ref) => {
|
||||
const { t } = useTranslation();
|
||||
const footerRef = React.useRef<HTMLDivElement>(null);
|
||||
const currentYear = new Date().getFullYear();
|
||||
|
||||
// Company info - full for public pages, minimal for internal
|
||||
const defaultCompanyInfo: CompanyInfo = compact ? {
|
||||
name: 'Panadería IA',
|
||||
name: t('common:app.name', 'Panadería IA'),
|
||||
} : {
|
||||
name: 'Panadería IA',
|
||||
description: 'Sistema inteligente de gestión para panaderías. Optimiza tu producción, inventario y ventas con inteligencia artificial.',
|
||||
name: t('common:app.name', 'Panadería IA'),
|
||||
description: t('common:footer.company_description', 'Sistema inteligente de gestión para panaderías. Optimiza tu producción, inventario y ventas con inteligencia artificial.'),
|
||||
email: 'contacto@panaderia-ia.com',
|
||||
website: 'https://panaderia-ia.com',
|
||||
};
|
||||
@@ -169,33 +171,33 @@ export const Footer = forwardRef<FooterRef, FooterProps>(({
|
||||
const defaultSections: FooterSection[] = compact ? [] : [
|
||||
{
|
||||
id: 'product',
|
||||
title: 'Producto',
|
||||
title: t('common:footer.sections.product', 'Producto'),
|
||||
links: [
|
||||
{ id: 'dashboard', label: 'Dashboard', href: '/dashboard' },
|
||||
{ id: 'inventory', label: 'Inventario', href: '/inventory' },
|
||||
{ id: 'production', label: 'Producción', href: '/production' },
|
||||
{ id: 'sales', label: 'Ventas', href: '/sales' },
|
||||
{ id: 'forecasting', label: 'Predicciones', href: '/forecasting' },
|
||||
{ id: 'dashboard', label: t('common:footer.links.dashboard', 'Dashboard'), href: '/dashboard' },
|
||||
{ id: 'inventory', label: t('common:footer.links.inventory', 'Inventario'), href: '/inventory' },
|
||||
{ id: 'production', label: t('common:footer.links.production', 'Producción'), href: '/production' },
|
||||
{ id: 'sales', label: t('common:footer.links.sales', 'Ventas'), href: '/sales' },
|
||||
{ id: 'forecasting', label: t('common:footer.links.forecasting', 'Predicciones'), href: '/forecasting' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'support',
|
||||
title: 'Soporte',
|
||||
title: t('common:footer.sections.support', 'Soporte'),
|
||||
links: [
|
||||
{ id: 'help', label: 'Centro de Ayuda', href: '/help', icon: HelpCircle },
|
||||
{ id: 'docs', label: 'Documentación', href: '/help/docs', icon: FileText },
|
||||
{ id: 'contact', label: 'Contacto', href: '/help/support', icon: MessageSquare },
|
||||
{ id: 'feedback', label: 'Feedback', href: '/help/feedback' },
|
||||
{ id: 'help', label: t('common:footer.links.help', 'Centro de Ayuda'), href: '/help', icon: HelpCircle },
|
||||
{ id: 'docs', label: t('common:footer.links.docs', 'Documentación'), href: '/help/docs', icon: FileText },
|
||||
{ id: 'contact', label: t('common:footer.links.contact', 'Contacto'), href: '/help/support', icon: MessageSquare },
|
||||
{ id: 'feedback', label: t('common:footer.links.feedback', 'Feedback'), href: '/help/feedback' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'company',
|
||||
title: 'Empresa',
|
||||
title: t('common:footer.sections.company', 'Empresa'),
|
||||
links: [
|
||||
{ id: 'about', label: 'Acerca de', href: '/about', external: true },
|
||||
{ id: 'blog', label: 'Blog', href: 'https://blog.panaderia-ia.com', external: true },
|
||||
{ id: 'careers', label: 'Carreras', href: 'https://careers.panaderia-ia.com', external: true },
|
||||
{ id: 'press', label: 'Prensa', href: '/press', external: true },
|
||||
{ id: 'about', label: t('common:footer.links.about', 'Acerca de'), href: '/about', external: true },
|
||||
{ id: 'blog', label: t('common:footer.links.blog', 'Blog'), href: 'https://blog.panaderia-ia.com', external: true },
|
||||
{ id: 'careers', label: t('common:footer.links.careers', 'Carreras'), href: 'https://careers.panaderia-ia.com', external: true },
|
||||
{ id: 'press', label: t('common:footer.links.press', 'Prensa'), href: '/press', external: true },
|
||||
],
|
||||
},
|
||||
];
|
||||
@@ -206,19 +208,19 @@ export const Footer = forwardRef<FooterRef, FooterProps>(({
|
||||
const defaultSocialLinks: SocialLink[] = compact ? [] : [
|
||||
{
|
||||
id: 'twitter',
|
||||
label: 'Twitter',
|
||||
label: t('common:footer.social_labels.twitter', 'Twitter'),
|
||||
href: 'https://twitter.com/panaderia-ia',
|
||||
icon: Twitter,
|
||||
},
|
||||
{
|
||||
id: 'linkedin',
|
||||
label: 'LinkedIn',
|
||||
label: t('common:footer.social_labels.linkedin', 'LinkedIn'),
|
||||
href: 'https://linkedin.com/company/panaderia-ia',
|
||||
icon: Linkedin,
|
||||
},
|
||||
{
|
||||
id: 'github',
|
||||
label: 'GitHub',
|
||||
label: t('common:footer.social_labels.github', 'GitHub'),
|
||||
href: 'https://github.com/panaderia-ia',
|
||||
icon: Github,
|
||||
},
|
||||
@@ -228,9 +230,9 @@ export const Footer = forwardRef<FooterRef, FooterProps>(({
|
||||
|
||||
// Privacy links
|
||||
const privacyLinks: FooterLink[] = [
|
||||
{ id: 'privacy', label: 'Privacidad', href: '/privacy', icon: Shield },
|
||||
{ id: 'terms', label: 'Términos', href: '/terms', icon: FileText },
|
||||
{ id: 'cookies', label: 'Cookies', href: '/cookies' },
|
||||
{ id: 'privacy', label: t('common:footer.links.privacy', 'Privacidad'), href: '/privacy', icon: Shield },
|
||||
{ id: 'terms', label: t('common:footer.links.terms', 'Términos'), href: '/terms', icon: FileText },
|
||||
{ id: 'cookies', label: t('common:footer.links.cookies', 'Cookies'), href: '/cookies' },
|
||||
];
|
||||
|
||||
// Scroll into view
|
||||
@@ -375,7 +377,7 @@ export const Footer = forwardRef<FooterRef, FooterProps>(({
|
||||
{socialLinksToShow.length > 0 && (
|
||||
<div className="border-t border-[var(--border-primary)] pt-6 mb-6">
|
||||
<div className="flex flex-col sm:flex-row items-center justify-between gap-4">
|
||||
<p className="text-sm text-[var(--text-secondary)]">Síguenos en redes sociales</p>
|
||||
<p className="text-sm text-[var(--text-secondary)]">{t('common:footer.social_follow', 'Síguenos en redes sociales')}</p>
|
||||
<div className="flex items-center gap-2">
|
||||
{socialLinksToShow.map((social) => renderSocialLink(social))}
|
||||
</div>
|
||||
@@ -404,13 +406,13 @@ export const Footer = forwardRef<FooterRef, FooterProps>(({
|
||||
to="/privacy"
|
||||
className="text-[var(--text-tertiary)] hover:text-[var(--text-primary)] transition-colors duration-200"
|
||||
>
|
||||
Privacidad
|
||||
{t('common:footer.links.privacy', 'Privacidad')}
|
||||
</Link>
|
||||
<Link
|
||||
to="/terms"
|
||||
className="text-[var(--text-tertiary)] hover:text-[var(--text-primary)] transition-colors duration-200"
|
||||
>
|
||||
Términos
|
||||
{t('common:footer.links.terms', 'Términos')}
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -148,7 +148,7 @@ export const Header = forwardRef<HeaderRef, HeaderProps>(({
|
||||
className
|
||||
)}
|
||||
role="banner"
|
||||
aria-label="Navegación principal"
|
||||
aria-label={t('common:header.main_navigation', 'Navegación principal')}
|
||||
>
|
||||
{/* Left section */}
|
||||
<div className="flex items-center gap-2 sm:gap-4 flex-1 min-w-0 h-full">
|
||||
@@ -158,7 +158,7 @@ export const Header = forwardRef<HeaderRef, HeaderProps>(({
|
||||
size="sm"
|
||||
onClick={onMenuClick}
|
||||
className="lg:hidden w-10 h-10 p-0 flex items-center justify-center hover:bg-[var(--bg-secondary)] active:scale-95 transition-all duration-150"
|
||||
aria-label="Abrir menú de navegación"
|
||||
aria-label={t('common:header.open_menu', 'Abrir menú de navegación')}
|
||||
>
|
||||
<Menu className="h-5 w-5 text-[var(--text-primary)]" />
|
||||
</Button>
|
||||
@@ -176,7 +176,7 @@ export const Header = forwardRef<HeaderRef, HeaderProps>(({
|
||||
'self-center',
|
||||
sidebarCollapsed ? 'lg:block' : 'lg:hidden xl:block'
|
||||
)}>
|
||||
Panadería IA
|
||||
{t('common:app.name', 'Panadería IA')}
|
||||
</h1>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -2,6 +2,7 @@ import React, { forwardRef } from 'react';
|
||||
import { clsx } from 'clsx';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { Button, ThemeToggle } from '../../ui';
|
||||
import { CompactLanguageSelector } from '../../ui/LanguageSelector';
|
||||
|
||||
export interface PublicHeaderProps {
|
||||
className?: string;
|
||||
@@ -17,6 +18,10 @@ export interface PublicHeaderProps {
|
||||
* Show authentication buttons (login/register)
|
||||
*/
|
||||
showAuthButtons?: boolean;
|
||||
/**
|
||||
* Show language selector
|
||||
*/
|
||||
showLanguageSelector?: boolean;
|
||||
/**
|
||||
* Custom navigation items
|
||||
*/
|
||||
@@ -53,6 +58,7 @@ export const PublicHeader = forwardRef<PublicHeaderRef, PublicHeaderProps>(({
|
||||
logo,
|
||||
showThemeToggle = true,
|
||||
showAuthButtons = true,
|
||||
showLanguageSelector = true,
|
||||
navigationItems = [],
|
||||
variant = 'default',
|
||||
}, ref) => {
|
||||
@@ -149,6 +155,11 @@ export const PublicHeader = forwardRef<PublicHeaderRef, PublicHeaderProps>(({
|
||||
|
||||
{/* Right side actions */}
|
||||
<div className="flex items-center gap-3">
|
||||
{/* Language selector */}
|
||||
{showLanguageSelector && (
|
||||
<CompactLanguageSelector className="hidden sm:flex" />
|
||||
)}
|
||||
|
||||
{/* Theme toggle */}
|
||||
{showThemeToggle && (
|
||||
<ThemeToggle
|
||||
@@ -162,8 +173,8 @@ export const PublicHeader = forwardRef<PublicHeaderRef, PublicHeaderProps>(({
|
||||
{showAuthButtons && (
|
||||
<div className="flex items-center gap-2">
|
||||
<Link to="/login">
|
||||
<Button
|
||||
variant="ghost"
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="hidden sm:inline-flex"
|
||||
>
|
||||
@@ -171,7 +182,7 @@ export const PublicHeader = forwardRef<PublicHeaderRef, PublicHeaderProps>(({
|
||||
</Button>
|
||||
</Link>
|
||||
<Link to="/register">
|
||||
<Button
|
||||
<Button
|
||||
size="sm"
|
||||
className="bg-[var(--color-primary)] hover:bg-[var(--color-primary-dark)] text-white"
|
||||
>
|
||||
@@ -219,7 +230,17 @@ export const PublicHeader = forwardRef<PublicHeaderRef, PublicHeaderProps>(({
|
||||
{renderNavLink(item)}
|
||||
</div>
|
||||
))}
|
||||
|
||||
|
||||
{/* Mobile language selector */}
|
||||
{showLanguageSelector && (
|
||||
<div className="py-2 border-b border-[var(--border-primary)] sm:hidden">
|
||||
<div className="text-sm font-medium text-[var(--text-secondary)] mb-2">
|
||||
Idioma
|
||||
</div>
|
||||
<CompactLanguageSelector className="w-full" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Mobile auth buttons */}
|
||||
{showAuthButtons && (
|
||||
<div className="flex flex-col gap-2 pt-4 sm:hidden">
|
||||
|
||||
@@ -21,6 +21,7 @@ export interface PublicLayoutProps {
|
||||
headerProps?: {
|
||||
showThemeToggle?: boolean;
|
||||
showAuthButtons?: boolean;
|
||||
showLanguageSelector?: boolean;
|
||||
navigationItems?: Array<{
|
||||
id: string;
|
||||
label: string;
|
||||
|
||||
@@ -732,7 +732,7 @@ export const Sidebar = forwardRef<SidebarRef, SidebarProps>(({
|
||||
isCollapsed ? 'justify-center p-2 h-10 w-10 mx-auto rounded-lg' : 'p-4 gap-3',
|
||||
isProfileMenuOpen && 'bg-[var(--bg-secondary)]'
|
||||
)}
|
||||
aria-label="Menú de perfil"
|
||||
aria-label={t('common:profile.profile_menu', 'Menú de perfil')}
|
||||
aria-expanded={isProfileMenuOpen}
|
||||
aria-haspopup="true"
|
||||
>
|
||||
@@ -767,25 +767,25 @@ export const Sidebar = forwardRef<SidebarRef, SidebarProps>(({
|
||||
<div className="py-1">
|
||||
<button
|
||||
onClick={() => {
|
||||
navigate('/app/settings/profile');
|
||||
navigate('/app/settings/personal-info');
|
||||
setIsProfileMenuOpen(false);
|
||||
if (onClose) onClose();
|
||||
}}
|
||||
className="w-full px-4 py-2 text-left text-sm flex items-center gap-3 hover:bg-[var(--bg-secondary)] transition-colors"
|
||||
>
|
||||
<User className="h-4 w-4" />
|
||||
Perfil
|
||||
{t('common:profile.my_profile', 'Mi perfil')}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
navigate('/app/settings');
|
||||
navigate('/app/settings/organizations');
|
||||
setIsProfileMenuOpen(false);
|
||||
if (onClose) onClose();
|
||||
}}
|
||||
className="w-full px-4 py-2 text-left text-sm flex items-center gap-3 hover:bg-[var(--bg-secondary)] transition-colors"
|
||||
>
|
||||
<Settings className="h-4 w-4" />
|
||||
Configuración
|
||||
<Factory className="h-4 w-4" />
|
||||
{t('common:profile.my_locations', 'Mis Locales')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -795,7 +795,7 @@ export const Sidebar = forwardRef<SidebarRef, SidebarProps>(({
|
||||
className="w-full px-4 py-2 text-left text-sm flex items-center gap-3 hover:bg-[var(--bg-secondary)] transition-colors text-[var(--color-error)]"
|
||||
>
|
||||
<LogOut className="h-4 w-4" />
|
||||
Cerrar Sesión
|
||||
{t('common:profile.logout', 'Cerrar Sesión')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -852,7 +852,7 @@ export const Sidebar = forwardRef<SidebarRef, SidebarProps>(({
|
||||
PI
|
||||
</div>
|
||||
<h2 className="text-lg font-semibold text-[var(--text-primary)]">
|
||||
Panadería IA
|
||||
{t('common:app.name', 'Panadería IA')}
|
||||
</h2>
|
||||
</div>
|
||||
<Button
|
||||
@@ -860,7 +860,7 @@ export const Sidebar = forwardRef<SidebarRef, SidebarProps>(({
|
||||
size="sm"
|
||||
onClick={onClose}
|
||||
className="p-2 hover:bg-[var(--bg-secondary)]"
|
||||
aria-label="Cerrar navegación"
|
||||
aria-label={t('common:profile.close_navigation', 'Cerrar navegación')}
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</Button>
|
||||
@@ -931,7 +931,7 @@ export const Sidebar = forwardRef<SidebarRef, SidebarProps>(({
|
||||
'hover:bg-[var(--bg-secondary)] focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)]/20',
|
||||
isProfileMenuOpen && 'bg-[var(--bg-secondary)]'
|
||||
)}
|
||||
aria-label="Menú de perfil"
|
||||
aria-label={t('common:profile.profile_menu', 'Menú de perfil')}
|
||||
aria-expanded={isProfileMenuOpen}
|
||||
aria-haspopup="true"
|
||||
>
|
||||
@@ -966,7 +966,7 @@ export const Sidebar = forwardRef<SidebarRef, SidebarProps>(({
|
||||
className="w-full px-4 py-2 text-left text-sm flex items-center gap-3 hover:bg-[var(--bg-tertiary)] transition-colors"
|
||||
>
|
||||
<User className="h-4 w-4" />
|
||||
Perfil
|
||||
{t('common:profile.profile', 'Perfil')}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
@@ -977,7 +977,7 @@ export const Sidebar = forwardRef<SidebarRef, SidebarProps>(({
|
||||
className="w-full px-4 py-2 text-left text-sm flex items-center gap-3 hover:bg-[var(--bg-tertiary)] transition-colors"
|
||||
>
|
||||
<Settings className="h-4 w-4" />
|
||||
Configuración
|
||||
{t('common:profile.settings', 'Configuración')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -987,7 +987,7 @@ export const Sidebar = forwardRef<SidebarRef, SidebarProps>(({
|
||||
className="w-full px-4 py-2 text-left text-sm flex items-center gap-3 hover:bg-[var(--bg-tertiary)] transition-colors text-[var(--color-error)]"
|
||||
>
|
||||
<LogOut className="h-4 w-4" />
|
||||
Cerrar Sesión
|
||||
{t('common:profile.logout', 'Cerrar Sesión')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import React, { forwardRef, useState, useRef, useEffect, useMemo } from 'react';
|
||||
import { clsx } from 'clsx';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
export interface DatePickerProps extends Omit<React.InputHTMLAttributes<HTMLInputElement>, 'onChange' | 'value'> {
|
||||
label?: string;
|
||||
@@ -37,7 +38,7 @@ const DatePicker = forwardRef<HTMLInputElement, DatePickerProps>(({
|
||||
label,
|
||||
error,
|
||||
helperText,
|
||||
placeholder = 'Seleccionar fecha',
|
||||
placeholder,
|
||||
size = 'md',
|
||||
variant = 'outline',
|
||||
value,
|
||||
@@ -68,6 +69,7 @@ const DatePicker = forwardRef<HTMLInputElement, DatePickerProps>(({
|
||||
disabled,
|
||||
...props
|
||||
}, ref) => {
|
||||
const { t } = useTranslation(['ui']);
|
||||
const datePickerId = id || `datepicker-${Math.random().toString(36).substr(2, 9)}`;
|
||||
const [internalValue, setInternalValue] = useState<Date | null>(
|
||||
value !== undefined ? value : defaultValue || null
|
||||
@@ -110,7 +112,7 @@ const DatePicker = forwardRef<HTMLInputElement, DatePickerProps>(({
|
||||
},
|
||||
};
|
||||
|
||||
const t = translations[locale];
|
||||
const localT = translations[locale];
|
||||
|
||||
// Format date for display
|
||||
const formatDate = (date: Date | null): string => {
|
||||
@@ -422,7 +424,7 @@ const DatePicker = forwardRef<HTMLInputElement, DatePickerProps>(({
|
||||
id={datePickerId}
|
||||
type="text"
|
||||
className={inputClasses}
|
||||
placeholder={placeholder}
|
||||
placeholder={placeholder || t('ui:datepicker.placeholder', 'Seleccionar fecha')}
|
||||
value={inputValue}
|
||||
onChange={handleInputChange}
|
||||
onFocus={(e) => {
|
||||
@@ -475,7 +477,7 @@ const DatePicker = forwardRef<HTMLInputElement, DatePickerProps>(({
|
||||
value={currentMonth}
|
||||
onChange={(e) => setCurrentMonth(parseInt(e.target.value))}
|
||||
>
|
||||
{t.months.map((month, index) => (
|
||||
{localT.months.map((month, index) => (
|
||||
<option key={index} value={index}>
|
||||
{month}
|
||||
</option>
|
||||
@@ -512,7 +514,7 @@ const DatePicker = forwardRef<HTMLInputElement, DatePickerProps>(({
|
||||
|
||||
{/* Weekdays */}
|
||||
<div className="grid grid-cols-7 gap-1 mb-2">
|
||||
{(firstDayOfWeek === 0 ? t.weekdays : [...t.weekdays.slice(1), t.weekdays[0]]).map((day) => (
|
||||
{(firstDayOfWeek === 0 ? localT.weekdays : [...localT.weekdays.slice(1), localT.weekdays[0]]).map((day) => (
|
||||
<div key={day} className="text-xs font-medium text-text-tertiary text-center p-2">
|
||||
{day}
|
||||
</div>
|
||||
@@ -582,7 +584,7 @@ const DatePicker = forwardRef<HTMLInputElement, DatePickerProps>(({
|
||||
className="px-3 py-1 text-sm text-color-primary hover:bg-color-primary/10 rounded transition-colors duration-150"
|
||||
onClick={handleTodayClick}
|
||||
>
|
||||
{t.today}
|
||||
{localT.today}
|
||||
</button>
|
||||
)}
|
||||
|
||||
@@ -592,7 +594,7 @@ const DatePicker = forwardRef<HTMLInputElement, DatePickerProps>(({
|
||||
className="px-3 py-1 text-sm text-text-tertiary hover:text-color-error hover:bg-color-error/10 rounded transition-colors duration-150"
|
||||
onClick={handleClear}
|
||||
>
|
||||
{t.clear}
|
||||
{localT.clear}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
interface PasswordCriteria {
|
||||
label: string;
|
||||
@@ -18,29 +19,31 @@ export const PasswordCriteria: React.FC<PasswordCriteriaProps> = ({
|
||||
className = '',
|
||||
showOnlyFailed = false
|
||||
}) => {
|
||||
const { t } = useTranslation(['ui']);
|
||||
|
||||
const criteria: PasswordCriteria[] = [
|
||||
{
|
||||
label: 'Al menos 8 caracteres',
|
||||
label: t('ui:password_criteria.min_length', 'Al menos 8 caracteres'),
|
||||
isValid: password.length >= 8,
|
||||
checkFn: (pwd) => pwd.length >= 8
|
||||
},
|
||||
{
|
||||
label: 'Máximo 128 caracteres',
|
||||
label: t('ui:password_criteria.max_length', 'Máximo 128 caracteres'),
|
||||
isValid: password.length <= 128,
|
||||
checkFn: (pwd) => pwd.length <= 128
|
||||
},
|
||||
{
|
||||
label: 'Al menos una letra mayúscula',
|
||||
label: t('ui:password_criteria.uppercase', 'Al menos una letra mayúscula'),
|
||||
isValid: /[A-Z]/.test(password),
|
||||
regex: /[A-Z]/
|
||||
},
|
||||
{
|
||||
label: 'Al menos una letra minúscula',
|
||||
label: t('ui:password_criteria.lowercase', 'Al menos una letra minúscula'),
|
||||
isValid: /[a-z]/.test(password),
|
||||
regex: /[a-z]/
|
||||
},
|
||||
{
|
||||
label: 'Al menos un número',
|
||||
label: t('ui:password_criteria.number', 'Al menos un número'),
|
||||
isValid: /\d/.test(password),
|
||||
regex: /\d/
|
||||
}
|
||||
@@ -104,28 +107,28 @@ export const validatePassword = (password: string): boolean => {
|
||||
);
|
||||
};
|
||||
|
||||
export const getPasswordErrors = (password: string): string[] => {
|
||||
export const getPasswordErrors = (password: string, t?: (key: string, fallback: string) => string): string[] => {
|
||||
const errors: string[] = [];
|
||||
|
||||
|
||||
if (password.length < 8) {
|
||||
errors.push('La contraseña debe tener al menos 8 caracteres');
|
||||
errors.push(t?.('ui:password_criteria.errors.min_length', 'La contraseña debe tener al menos 8 caracteres') ?? 'La contraseña debe tener al menos 8 caracteres');
|
||||
}
|
||||
|
||||
|
||||
if (password.length > 128) {
|
||||
errors.push('La contraseña no puede exceder 128 caracteres');
|
||||
errors.push(t?.('ui:password_criteria.errors.max_length', 'La contraseña no puede exceder 128 caracteres') ?? 'La contraseña no puede exceder 128 caracteres');
|
||||
}
|
||||
|
||||
|
||||
if (!/[A-Z]/.test(password)) {
|
||||
errors.push('La contraseña debe contener al menos una letra mayúscula');
|
||||
errors.push(t?.('ui:password_criteria.errors.uppercase', 'La contraseña debe contener al menos una letra mayúscula') ?? 'La contraseña debe contener al menos una letra mayúscula');
|
||||
}
|
||||
|
||||
|
||||
if (!/[a-z]/.test(password)) {
|
||||
errors.push('La contraseña debe contener al menos una letra minúscula');
|
||||
errors.push(t?.('ui:password_criteria.errors.lowercase', 'La contraseña debe contener al menos una letra minúscula') ?? 'La contraseña debe contener al menos una letra minúscula');
|
||||
}
|
||||
|
||||
|
||||
if (!/\d/.test(password)) {
|
||||
errors.push('La contraseña debe contener al menos un número');
|
||||
errors.push(t?.('ui:password_criteria.errors.number', 'La contraseña debe contener al menos un número') ?? 'La contraseña debe contener al menos un número');
|
||||
}
|
||||
|
||||
|
||||
return errors;
|
||||
};
|
||||
@@ -20,6 +20,7 @@ export { StatsCard, StatsGrid } from './Stats';
|
||||
export { StatusCard, getStatusColor } from './StatusCard';
|
||||
export { StatusModal } from './StatusModal';
|
||||
export { TenantSwitcher } from './TenantSwitcher';
|
||||
export { LanguageSelector, CompactLanguageSelector } from './LanguageSelector';
|
||||
|
||||
// Export types
|
||||
export type { ButtonProps } from './Button';
|
||||
|
||||
Reference in New Issue
Block a user