Reorganize teh menus

This commit is contained in:
Urtzi Alfaro
2025-09-24 22:22:01 +02:00
parent dc6c6f213f
commit 6d4090f825
9 changed files with 2251 additions and 128 deletions

View File

@@ -104,6 +104,7 @@ const iconMap: Record<string, React.ComponentType<{ className?: string }>> = {
database: Store,
training: GraduationCap,
notifications: Bell,
bell: Bell,
settings: Settings,
user: User,
'credit-card': CreditCard,

View File

@@ -14,7 +14,7 @@
"sales": "Ventas",
"performance": "Rendimiento",
"insights": "Insights IA",
"data": "Datos",
"data": "Mi Panadería",
"weather": "Clima",
"traffic": "Tráfico",
"events": "Eventos",

View File

@@ -0,0 +1,577 @@
import React, { useState } from 'react';
import { Store, MapPin, Clock, Phone, Mail, Globe, Save, X, AlertCircle, Loader } from 'lucide-react';
import { Button, Card, Input, Select } from '../../../../components/ui';
import { PageHeader } from '../../../../components/layout';
import { useToast } from '../../../../hooks/ui/useToast';
import { useUpdateTenant } from '../../../../api/hooks/tenant';
import { useCurrentTenant, useTenantActions } from '../../../../stores/tenant.store';
interface BakeryConfig {
// General Info
name: string;
description: string;
email: string;
phone: string;
website: string;
// Location
address: string;
city: string;
postalCode: string;
country: string;
// Business
taxId: string;
currency: string;
timezone: string;
language: string;
}
interface BusinessHours {
[key: string]: {
open: string;
close: string;
closed: boolean;
};
}
const InformationPage: React.FC = () => {
const { addToast } = useToast();
const currentTenant = useCurrentTenant();
const { loadUserTenants, setCurrentTenant } = useTenantActions();
const tenantId = currentTenant?.id || '';
// Use the current tenant from the store instead of making additional API calls
// to avoid the 422 validation error on the tenant GET endpoint
const tenant = currentTenant;
const tenantLoading = !currentTenant;
const tenantError = null;
const updateTenantMutation = useUpdateTenant();
const [isLoading, setIsLoading] = useState(false);
const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false);
const [config, setConfig] = useState<BakeryConfig>({
name: '',
description: '',
email: '',
phone: '',
website: '',
address: '',
city: '',
postalCode: '',
country: '',
taxId: '',
currency: 'EUR',
timezone: 'Europe/Madrid',
language: 'es'
});
// Load user tenants on component mount to ensure fresh data
React.useEffect(() => {
loadUserTenants();
}, [loadUserTenants]);
// Update config when tenant data is loaded
React.useEffect(() => {
if (tenant) {
setConfig({
name: tenant.name || '',
description: tenant.description || '',
email: tenant.email || '',
phone: tenant.phone || '',
website: tenant.website || '',
address: tenant.address || '',
city: tenant.city || '',
postalCode: tenant.postal_code || '',
country: tenant.country || '',
taxId: '', // Not supported by backend yet
currency: 'EUR', // Default value
timezone: 'Europe/Madrid', // Default value
language: 'es' // Default value
});
setHasUnsavedChanges(false); // Reset unsaved changes when loading fresh data
}
}, [tenant]);
const [businessHours, setBusinessHours] = useState<BusinessHours>({
monday: { open: '07:00', close: '20:00', closed: false },
tuesday: { open: '07:00', close: '20:00', closed: false },
wednesday: { open: '07:00', close: '20:00', closed: false },
thursday: { open: '07:00', close: '20:00', closed: false },
friday: { open: '07:00', close: '20:00', closed: false },
saturday: { open: '08:00', close: '14:00', closed: false },
sunday: { open: '09:00', close: '13:00', closed: false }
});
const [errors, setErrors] = useState<Record<string, string>>({});
const daysOfWeek = [
{ key: 'monday', label: 'Lunes' },
{ key: 'tuesday', label: 'Martes' },
{ key: 'wednesday', label: 'Miércoles' },
{ key: 'thursday', label: 'Jueves' },
{ key: 'friday', label: 'Viernes' },
{ key: 'saturday', label: 'Sábado' },
{ key: 'sunday', label: 'Domingo' }
];
const currencyOptions = [
{ value: 'EUR', label: 'EUR (€)' },
{ value: 'USD', label: 'USD ($)' },
{ value: 'GBP', label: 'GBP (£)' }
];
const timezoneOptions = [
{ value: 'Europe/Madrid', label: 'Madrid (CET/CEST)' },
{ value: 'Atlantic/Canary', label: 'Canarias (WET/WEST)' },
{ value: 'Europe/London', label: 'Londres (GMT/BST)' }
];
const languageOptions = [
{ value: 'es', label: 'Español' },
{ value: 'ca', label: 'Català' },
{ value: 'en', label: 'English' }
];
const validateConfig = (): boolean => {
const newErrors: Record<string, string> = {};
if (!config.name.trim()) {
newErrors.name = 'El nombre es requerido';
}
if (!config.email.trim()) {
newErrors.email = 'El email es requerido';
} else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(config.email)) {
newErrors.email = 'Email inválido';
}
if (!config.address.trim()) {
newErrors.address = 'La dirección es requerida';
}
if (!config.city.trim()) {
newErrors.city = 'La ciudad es requerida';
}
setErrors(newErrors);
return Object.keys(newErrors).length === 0;
};
const handleSaveConfig = async () => {
if (!validateConfig() || !tenantId) return;
setIsLoading(true);
try {
const updateData = {
name: config.name,
description: config.description,
email: config.email,
phone: config.phone,
website: config.website,
address: config.address,
city: config.city,
postal_code: config.postalCode,
country: config.country
};
const updatedTenant = await updateTenantMutation.mutateAsync({
tenantId,
updateData
});
// Update the tenant store with the new data
if (updatedTenant) {
setCurrentTenant(updatedTenant);
// Force reload tenant list to ensure cache consistency
await loadUserTenants();
// Update localStorage to persist the changes
const tenantStorage = localStorage.getItem('tenant-storage');
if (tenantStorage) {
const parsedStorage = JSON.parse(tenantStorage);
if (parsedStorage.state && parsedStorage.state.currentTenant) {
parsedStorage.state.currentTenant = updatedTenant;
localStorage.setItem('tenant-storage', JSON.stringify(parsedStorage));
}
}
}
setHasUnsavedChanges(false);
addToast('Información actualizada correctamente', { type: 'success' });
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Error desconocido';
addToast(`Error al actualizar: ${errorMessage}`, { type: 'error' });
} finally {
setIsLoading(false);
}
};
const handleInputChange = (field: keyof BakeryConfig) => (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
setConfig(prev => ({ ...prev, [field]: e.target.value }));
setHasUnsavedChanges(true);
if (errors[field]) {
setErrors(prev => ({ ...prev, [field]: '' }));
}
};
const handleSelectChange = (field: keyof BakeryConfig) => (value: string) => {
setConfig(prev => ({ ...prev, [field]: value }));
setHasUnsavedChanges(true);
};
const handleHoursChange = (day: string, field: 'open' | 'close' | 'closed', value: string | boolean) => {
setBusinessHours(prev => ({
...prev,
[day]: {
...prev[day],
[field]: value
}
}));
setHasUnsavedChanges(true);
};
if (tenantLoading || !currentTenant) {
return (
<div className="p-6 space-y-6">
<PageHeader
title="Información de la Panadería"
description="Configura los datos básicos y preferencias de tu panadería"
/>
<div className="flex items-center justify-center h-64">
<Loader className="w-8 h-8 animate-spin" />
<span className="ml-2">Cargando información...</span>
</div>
</div>
);
}
if (tenantError) {
return (
<div className="p-6 space-y-6">
<PageHeader
title="Información de la Panadería"
description="Error al cargar la información"
/>
<Card className="p-6">
<div className="text-red-600">
Error al cargar la información: Error desconocido
</div>
</Card>
</div>
);
}
return (
<div className="p-6 space-y-6">
<PageHeader
title="Información de la Panadería"
description="Configura los datos básicos y preferencias de tu panadería"
/>
{/* Bakery Header */}
<Card className="p-6">
<div className="flex items-center gap-6">
<div className="w-16 h-16 bg-gradient-to-br from-[var(--color-primary)] to-[var(--color-primary-dark)] rounded-lg flex items-center justify-center text-white font-bold text-xl">
{config.name.charAt(0)}
</div>
<div className="flex-1">
<h1 className="text-2xl font-bold text-text-primary mb-1">
{config.name}
</h1>
<p className="text-text-secondary">{config.email}</p>
<p className="text-text-tertiary text-sm">{config.address}, {config.city}</p>
</div>
<div className="flex gap-2">
{hasUnsavedChanges && (
<div className="flex items-center gap-2 text-sm text-yellow-600">
<AlertCircle className="w-4 h-4" />
Cambios sin guardar
</div>
)}
</div>
</div>
</Card>
{/* Information Sections */}
<div className="space-y-8">
{/* General Information */}
<Card className="p-6">
<h3 className="text-lg font-semibold text-text-primary mb-6 flex items-center">
<Store className="w-5 h-5 mr-2" />
Información General
</h3>
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-6">
<Input
label="Nombre de la Panadería"
value={config.name}
onChange={handleInputChange('name')}
error={errors.name}
disabled={isLoading}
placeholder="Nombre de tu panadería"
leftIcon={<Store className="w-4 h-4" />}
/>
<Input
type="email"
label="Email de Contacto"
value={config.email}
onChange={handleInputChange('email')}
error={errors.email}
disabled={isLoading}
placeholder="contacto@panaderia.com"
leftIcon={<Mail className="w-4 h-4" />}
/>
<Input
type="tel"
label="Teléfono"
value={config.phone}
onChange={handleInputChange('phone')}
error={errors.phone}
disabled={isLoading}
placeholder="+34 912 345 678"
leftIcon={<Phone className="w-4 h-4" />}
/>
<Input
label="Sitio Web"
value={config.website}
onChange={handleInputChange('website')}
disabled={isLoading}
placeholder="https://tu-panaderia.com"
leftIcon={<Globe className="w-4 h-4" />}
className="md:col-span-2 xl:col-span-3"
/>
</div>
<div className="mt-6">
<label className="block text-sm font-medium text-text-secondary mb-2">
Descripción
</label>
<textarea
value={config.description}
onChange={handleInputChange('description')}
disabled={isLoading}
rows={3}
className="w-full px-3 py-2 border border-[var(--border-primary)] rounded-lg resize-none bg-[var(--bg-primary)] text-[var(--text-primary)] disabled:bg-[var(--bg-secondary)] disabled:text-[var(--text-secondary)]"
placeholder="Describe tu panadería..."
/>
</div>
</Card>
{/* Location Information */}
<Card className="p-6">
<h3 className="text-lg font-semibold text-text-primary mb-6 flex items-center">
<MapPin className="w-5 h-5 mr-2" />
Ubicación
</h3>
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-6">
<Input
label="Dirección"
value={config.address}
onChange={handleInputChange('address')}
error={errors.address}
disabled={isLoading}
placeholder="Calle, número, etc."
leftIcon={<MapPin className="w-4 h-4" />}
className="md:col-span-2"
/>
<Input
label="Ciudad"
value={config.city}
onChange={handleInputChange('city')}
error={errors.city}
disabled={isLoading}
placeholder="Ciudad"
/>
<Input
label="Código Postal"
value={config.postalCode}
onChange={handleInputChange('postalCode')}
disabled={isLoading}
placeholder="28001"
/>
<Input
label="País"
value={config.country}
onChange={handleInputChange('country')}
disabled={isLoading}
placeholder="España"
/>
</div>
</Card>
{/* Business Information */}
<Card className="p-6">
<h3 className="text-lg font-semibold text-text-primary mb-6">Datos de Empresa</h3>
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-6">
<Input
label="NIF/CIF"
value={config.taxId}
onChange={handleInputChange('taxId')}
disabled={isLoading}
placeholder="B12345678"
/>
<Select
label="Moneda"
options={currencyOptions}
value={config.currency}
onChange={(value) => handleSelectChange('currency')(value as string)}
disabled={isLoading}
/>
<Select
label="Zona Horaria"
options={timezoneOptions}
value={config.timezone}
onChange={(value) => handleSelectChange('timezone')(value as string)}
disabled={isLoading}
/>
<Select
label="Idioma"
options={languageOptions}
value={config.language}
onChange={(value) => handleSelectChange('language')(value as string)}
disabled={isLoading}
/>
</div>
</Card>
{/* Business Hours */}
<Card className="p-6">
<h3 className="text-lg font-semibold text-text-primary mb-6 flex items-center">
<Clock className="w-5 h-5 mr-2" />
Horarios de Apertura
</h3>
<div className="space-y-4">
{daysOfWeek.map((day) => {
const hours = businessHours[day.key];
return (
<div key={day.key} className="grid grid-cols-12 items-center gap-4 p-4 border border-border-primary rounded-lg">
{/* Day Name */}
<div className="col-span-2">
<span className="text-sm font-medium text-text-secondary">{day.label}</span>
</div>
{/* Closed Checkbox */}
<div className="col-span-2">
<label className="flex items-center gap-2">
<input
type="checkbox"
checked={hours.closed}
onChange={(e) => handleHoursChange(day.key, 'closed', e.target.checked)}
disabled={isLoading}
className="rounded border-border-primary"
/>
<span className="text-sm text-text-secondary">Cerrado</span>
</label>
</div>
{/* Time Inputs */}
<div className="col-span-8 flex items-center gap-6">
{!hours.closed ? (
<>
<div className="flex-1">
<label className="block text-xs text-text-tertiary mb-1">Apertura</label>
<input
type="time"
value={hours.open}
onChange={(e) => handleHoursChange(day.key, 'open', e.target.value)}
disabled={isLoading}
className="w-full px-3 py-2 border border-[var(--border-primary)] rounded-lg text-sm bg-[var(--bg-primary)] text-[var(--text-primary)] disabled:bg-[var(--bg-secondary)] disabled:text-[var(--text-secondary)]"
/>
</div>
<div className="flex-1">
<label className="block text-xs text-text-tertiary mb-1">Cierre</label>
<input
type="time"
value={hours.close}
onChange={(e) => handleHoursChange(day.key, 'close', e.target.value)}
disabled={isLoading}
className="w-full px-3 py-2 border border-[var(--border-primary)] rounded-lg text-sm bg-[var(--bg-primary)] text-[var(--text-primary)] disabled:bg-[var(--bg-secondary)] disabled:text-[var(--text-secondary)]"
/>
</div>
</>
) : (
<div className="text-sm text-text-tertiary italic">
Cerrado todo el día
</div>
)}
</div>
</div>
);
})}
</div>
</Card>
</div>
{/* Floating Save Button */}
{hasUnsavedChanges && (
<div className="fixed bottom-6 right-6 z-50">
<Card className="p-4 shadow-lg">
<div className="flex items-center gap-3">
<div className="flex items-center gap-2 text-sm text-text-secondary">
<AlertCircle className="w-4 h-4 text-yellow-500" />
Tienes cambios sin guardar
</div>
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
onClick={() => {
// Reset to original values
if (tenant) {
setConfig({
name: tenant.name || '',
description: tenant.description || '',
email: tenant.email || '',
phone: tenant.phone || '',
website: tenant.website || '',
address: tenant.address || '',
city: tenant.city || '',
postalCode: tenant.postal_code || '',
country: tenant.country || '',
taxId: '',
currency: 'EUR',
timezone: 'Europe/Madrid',
language: 'es'
});
}
setHasUnsavedChanges(false);
}}
disabled={isLoading}
>
<X className="w-4 h-4" />
Descartar
</Button>
<Button
variant="primary"
size="sm"
onClick={handleSaveConfig}
isLoading={isLoading}
loadingText="Guardando..."
>
<Save className="w-4 h-4" />
Guardar
</Button>
</div>
</div>
</Card>
</div>
)}
</div>
);
};
export default InformationPage;

View File

@@ -1,12 +1,16 @@
import React, { useState, useMemo } from 'react';
import { Plus, Minus, ShoppingCart, CreditCard, Banknote, Calculator, User, Receipt, Package, Euro, TrendingUp, Clock } from 'lucide-react';
import { Button, Input, Card, Badge, StatsGrid, StatusCard, getStatusColor } from '../../../../components/ui';
import { Plus, Minus, ShoppingCart, CreditCard, Banknote, Calculator, User, Receipt, Package, Euro, TrendingUp, Clock, ToggleLeft, ToggleRight, Settings, Zap, Wifi, WifiOff, AlertCircle, CheckCircle, Loader, Eye, EyeOff, Info, Trash2 } from 'lucide-react';
import { Button, Input, Card, Badge, StatsGrid, StatusCard, getStatusColor, Tabs, Modal, Select } from '../../../../components/ui';
import { PageHeader } from '../../../../components/layout';
import { LoadingSpinner } from '../../../../components/shared';
import { formatters } from '../../../../components/ui/Stats/StatsPresets';
import { useIngredients } from '../../../../api/hooks/inventory';
import { useTenantId } from '../../../../hooks/useTenantId';
import { ProductType, ProductCategory, IngredientResponse } from '../../../../api/types/inventory';
import { useToast } from '../../../../hooks/ui/useToast';
import { usePOSConfigurationData, usePOSConfigurationManager } from '../../../../api/hooks/pos';
import { POSConfiguration, POSProviderConfig } from '../../../../api/types/pos';
import { posService } from '../../../../api/services/pos';
interface CartItem {
id: string;
@@ -27,8 +31,23 @@ const POSPage: React.FC = () => {
});
const [paymentMethod, setPaymentMethod] = useState<'cash' | 'card' | 'transfer'>('cash');
const [cashReceived, setCashReceived] = useState('');
const [posMode, setPosMode] = useState<'manual' | 'automatic'>('manual');
const [showPOSConfig, setShowPOSConfig] = useState(false);
// POS Configuration State
const [showAddPosModal, setShowAddPosModal] = useState(false);
const [showEditPosModal, setShowEditPosModal] = useState(false);
const [selectedPosConfig, setSelectedPosConfig] = useState<POSConfiguration | null>(null);
const [posFormData, setPosFormData] = useState<any>({});
const [testingConnection, setTestingConnection] = useState<string | null>(null);
const [showCredentials, setShowCredentials] = useState<Record<string, boolean>>({});
const tenantId = useTenantId();
const { addToast } = useToast();
// POS Configuration hooks
const posData = usePOSConfigurationData(tenantId);
const posManager = usePOSConfigurationManager(tenantId);
// Fetch finished products from API
const {
@@ -73,12 +92,185 @@ const POSPage: React.FC = () => {
return Array.from(categoryMap.values());
}, [products]);
// POS Providers Configuration
const supportedProviders: POSProviderConfig[] = [
{
id: 'toast',
name: 'Toast POS',
logo: '🍞',
description: 'Sistema POS líder para restaurantes y panaderías. Muy popular en España.',
features: ['Gestión de pedidos', 'Sincronización de inventario', 'Pagos integrados', 'Reportes en tiempo real'],
required_fields: [
{ field: 'api_key', label: 'API Key', type: 'password', required: true, help_text: 'Obten tu API key desde Toast Dashboard > Settings > Integrations' },
{ field: 'restaurant_guid', label: 'Restaurant GUID', type: 'text', required: true, help_text: 'ID único del restaurante en Toast' },
{ field: 'location_id', label: 'Location ID', type: 'text', required: true, help_text: 'ID de la ubicación específica' },
{ field: 'environment', label: 'Entorno', type: 'select', required: true, options: [
{ value: 'sandbox', label: 'Sandbox (Pruebas)' },
{ value: 'production', label: 'Producción' }
]},
],
},
{
id: 'square',
name: 'Square POS',
logo: '⬜',
description: 'Solución POS completa con tarifas transparentes. Ampliamente utilizada por pequeñas empresas.',
features: ['Procesamiento de pagos', 'Gestión de inventario', 'Análisis de ventas', 'Integración con e-commerce'],
required_fields: [
{ field: 'application_id', label: 'Application ID', type: 'text', required: true, help_text: 'ID de aplicación de Square Developer Dashboard' },
{ field: 'access_token', label: 'Access Token', type: 'password', required: true, help_text: 'Token de acceso para la API de Square' },
{ field: 'location_id', label: 'Location ID', type: 'text', required: true, help_text: 'ID de la ubicación de Square' },
{ field: 'webhook_signature_key', label: 'Webhook Signature Key', type: 'password', required: false, help_text: 'Clave para verificar webhooks (opcional)' },
{ field: 'environment', label: 'Entorno', type: 'select', required: true, options: [
{ value: 'sandbox', label: 'Sandbox (Pruebas)' },
{ value: 'production', label: 'Producción' }
]},
],
},
{
id: 'lightspeed',
name: 'Lightspeed POS',
logo: '⚡',
description: 'Sistema POS empresarial con API abierta e integración con múltiples herramientas.',
features: ['API REST completa', 'Gestión multi-ubicación', 'Reportes avanzados', 'Integración con contabilidad'],
required_fields: [
{ field: 'api_key', label: 'API Key', type: 'password', required: true, help_text: 'Clave API de Lightspeed Retail' },
{ field: 'api_secret', label: 'API Secret', type: 'password', required: true, help_text: 'Secreto API de Lightspeed Retail' },
{ field: 'account_id', label: 'Account ID', type: 'text', required: true, help_text: 'ID de cuenta de Lightspeed' },
{ field: 'shop_id', label: 'Shop ID', type: 'text', required: true, help_text: 'ID de la tienda específica' },
{ field: 'server_region', label: 'Región del Servidor', type: 'select', required: true, options: [
{ value: 'eu', label: 'Europa' },
{ value: 'us', label: 'Estados Unidos' },
{ value: 'ca', label: 'Canadá' }
]},
],
},
];
const filteredProducts = useMemo(() => {
return products.filter(product =>
selectedCategory === 'all' || product.category === selectedCategory
);
}, [products, selectedCategory]);
// Load POS configurations function for refetching after updates
const loadPosConfigurations = () => {
// This will trigger a refetch of POS configurations
// Note: posManager may not have refetch method available
console.log('POS configurations updated, consider implementing refetch if needed');
};
// POS Configuration Handlers
const handleAddPosConfiguration = () => {
setPosFormData({
provider: '',
config_name: '',
credentials: {},
sync_settings: {
auto_sync_enabled: true,
sync_interval_minutes: 5,
sync_sales: true,
sync_inventory: true,
sync_customers: true,
},
});
setShowAddPosModal(true);
};
const handleEditPosConfiguration = (config: POSConfiguration) => {
setSelectedPosConfig(config);
setPosFormData({
provider: (config as any).provider || (config as any).pos_system || '',
config_name: (config as any).config_name || (config as any).provider_name || '',
credentials: (config as any).credentials || {},
sync_settings: (config as any).sync_settings || {
auto_sync_enabled: true,
sync_interval_minutes: 5,
sync_sales: true,
sync_inventory: true,
sync_customers: true,
},
});
setShowEditPosModal(true);
};
const handleSavePosConfiguration = async () => {
try {
const provider = supportedProviders.find(p => p.id === posFormData.provider);
if (!provider) return;
// Validate required fields
const missingFields = provider.required_fields
.filter(field => field.required && !posFormData.credentials[field.field])
.map(field => field.label);
if (missingFields.length > 0) {
addToast(`Campos requeridos: ${missingFields.join(', ')}`, { type: 'error' });
return;
}
if (selectedPosConfig) {
// Update existing
await posService.updatePOSConfiguration({
tenant_id: tenantId,
config_id: selectedPosConfig.id,
...posFormData,
});
addToast('Configuración actualizada correctamente', { type: 'success' });
setShowEditPosModal(false);
loadPosConfigurations();
} else {
// Create new
await posService.createPOSConfiguration({
tenant_id: tenantId,
...posFormData,
});
addToast('Configuración creada correctamente', { type: 'success' });
setShowAddPosModal(false);
loadPosConfigurations();
}
} catch (error) {
addToast('Error al guardar la configuración', { type: 'error' });
}
};
const handleTestPosConnection = async (configId: string) => {
try {
setTestingConnection(configId);
const response = await posService.testPOSConnection({
tenant_id: tenantId,
config_id: configId,
});
if (response.success) {
addToast('Conexión exitosa', { type: 'success' });
} else {
addToast(`Error en la conexión: ${response.message || 'Error desconocido'}`, { type: 'error' });
}
} catch (error) {
addToast('Error al probar la conexión', { type: 'error' });
} finally {
setTestingConnection(null);
}
};
const handleDeletePosConfiguration = async (configId: string) => {
if (!window.confirm('¿Estás seguro de que deseas eliminar esta configuración?')) {
return;
}
try {
await posService.deletePOSConfiguration({
tenant_id: tenantId,
config_id: configId,
});
addToast('Configuración eliminada correctamente', { type: 'success' });
loadPosConfigurations();
} catch (error) {
addToast('Error al eliminar la configuración', { type: 'error' });
}
};
const addToCart = (product: typeof products[0]) => {
setCart(prevCart => {
const existingItem = prevCart.find(item => item.id === product.id);
@@ -237,6 +429,173 @@ const POSPage: React.FC = () => {
);
}
// POS Configuration Form Renderer
const renderPosConfigurationForm = () => {
const selectedProvider = supportedProviders.find(p => p.id === posFormData.provider);
return (
<div className="space-y-6">
<div>
<label className="block text-sm font-medium mb-2">Sistema POS</label>
<Select
value={posFormData.provider}
onChange={(value) => setPosFormData((prev: any) => ({ ...prev, provider: value as string, credentials: {} }))}
placeholder="Selecciona un sistema POS"
options={supportedProviders.map(provider => ({
value: provider.id,
label: `${provider.logo} ${provider.name}`,
description: provider.description
}))}
/>
</div>
{selectedProvider && (
<>
<div className="p-4 bg-blue-50 rounded-lg">
<h4 className="font-medium text-blue-900 mb-2">{selectedProvider.name}</h4>
<p className="text-sm text-blue-700 mb-3">{selectedProvider.description}</p>
<div className="flex flex-wrap gap-2">
{selectedProvider.features.map((feature, idx) => (
<Badge key={idx} variant="secondary" className="text-xs">
{feature}
</Badge>
))}
</div>
</div>
<div>
<label className="block text-sm font-medium mb-2">Nombre de la Configuración</label>
<Input
value={posFormData.config_name}
onChange={(e) => setPosFormData((prev: any) => ({ ...prev, config_name: e.target.value }))}
placeholder={`Mi ${selectedProvider.name} ${new Date().getFullYear()}`}
/>
</div>
<div className="space-y-4">
<h4 className="font-medium text-sm">Credenciales de API</h4>
{selectedProvider.required_fields.map(field => (
<div key={field.field}>
<div className="flex items-center justify-between mb-2">
<label className="block text-sm font-medium">
{field.label} {field.required && <span className="text-red-500">*</span>}
</label>
{field.type === 'password' && (
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => setShowCredentials(prev => ({
...prev,
[field.field]: !prev[field.field]
}))}
>
{showCredentials[field.field] ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
</Button>
)}
</div>
{field.type === 'select' ? (
<Select
value={posFormData.credentials[field.field] || ''}
onChange={(value) => setPosFormData(prev => ({
...prev,
credentials: { ...prev.credentials, [field.field]: value }
}))}
placeholder={`Selecciona ${field.label.toLowerCase()}`}
options={field.options?.map(option => ({
value: option.value,
label: option.label
})) || []}
/>
) : (
<Input
type={field.type === 'password' && !showCredentials[field.field] ? 'password' : 'text'}
value={posFormData.credentials[field.field] || ''}
onChange={(e) => setPosFormData(prev => ({
...prev,
credentials: { ...prev.credentials, [field.field]: e.target.value }
}))}
placeholder={field.placeholder}
/>
)}
{field.help_text && (
<p className="text-xs text-gray-500 mt-1 flex items-start">
<Info className="w-3 h-3 mt-0.5 mr-1 flex-shrink-0" />
{field.help_text}
</p>
)}
</div>
))}
</div>
<div className="space-y-4">
<h4 className="font-medium text-sm">Configuración de Sincronización</h4>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="flex items-center space-x-2">
<input
type="checkbox"
checked={posFormData.sync_settings?.auto_sync_enabled || false}
onChange={(e) => setPosFormData(prev => ({
...prev,
sync_settings: { ...prev.sync_settings, auto_sync_enabled: e.target.checked }
}))}
/>
<span className="text-sm">Sincronización automática</span>
</label>
</div>
<div>
<label className="block text-sm font-medium mb-1">Intervalo (minutos)</label>
<Select
value={posFormData.sync_settings?.sync_interval_minutes?.toString() || '5'}
onChange={(value) => setPosFormData(prev => ({
...prev,
sync_settings: { ...prev.sync_settings, sync_interval_minutes: parseInt(value as string) }
}))}
options={[
{ value: '5', label: '5 minutos' },
{ value: '15', label: '15 minutos' },
{ value: '30', label: '30 minutos' },
{ value: '60', label: '1 hora' }
]}
/>
</div>
<div>
<label className="flex items-center space-x-2">
<input
type="checkbox"
checked={posFormData.sync_settings?.sync_sales || false}
onChange={(e) => setPosFormData((prev: any) => ({
...prev,
sync_settings: { ...prev.sync_settings, sync_sales: e.target.checked }
}))}
/>
<span className="text-sm">Sincronizar ventas</span>
</label>
</div>
<div>
<label className="flex items-center space-x-2">
<input
type="checkbox"
checked={posFormData.sync_settings?.sync_inventory || false}
onChange={(e) => setPosFormData((prev: any) => ({
...prev,
sync_settings: { ...prev.sync_settings, sync_inventory: e.target.checked }
}))}
/>
<span className="text-sm">Sincronizar inventario</span>
</label>
</div>
</div>
</div>
</>
)}
</div>
);
};
return (
<div className="space-y-6">
<PageHeader
@@ -244,11 +603,58 @@ const POSPage: React.FC = () => {
description="Sistema de ventas para productos terminados"
/>
{/* Stats Grid */}
<StatsGrid
stats={stats}
columns={3}
/>
{/* POS Mode Toggle */}
<Card className="p-6">
<div className="flex items-center justify-between">
<div>
<h3 className="text-lg font-semibold text-[var(--text-primary)] mb-2">Modo de Operación</h3>
<p className="text-sm text-[var(--text-secondary)]">
{posMode === 'manual'
? 'Sistema POS manual interno - gestiona las ventas directamente desde la aplicación'
: 'Integración automática con sistemas POS externos - sincroniza datos automáticamente'
}
</p>
</div>
<div className="flex items-center gap-4">
<div className="flex items-center gap-3">
<span className={`text-sm ${posMode === 'manual' ? 'text-[var(--color-primary)] font-medium' : 'text-[var(--text-tertiary)]'}`}>
Manual
</span>
<button
onClick={() => setPosMode(posMode === 'manual' ? 'automatic' : 'manual')}
className="relative inline-flex items-center"
>
{posMode === 'manual' ? (
<ToggleLeft className="w-8 h-8 text-[var(--text-tertiary)] hover:text-[var(--color-primary)] transition-colors" />
) : (
<ToggleRight className="w-8 h-8 text-[var(--color-primary)] hover:text-[var(--color-primary-dark)] transition-colors" />
)}
</button>
<span className={`text-sm ${posMode === 'automatic' ? 'text-[var(--color-primary)] font-medium' : 'text-[var(--text-tertiary)]'}`}>
Automático
</span>
</div>
{posMode === 'automatic' && (
<Button
variant="outline"
onClick={() => setShowPOSConfig(!showPOSConfig)}
className="flex items-center gap-2"
>
<Settings className="w-4 h-4" />
Configurar POS
</Button>
)}
</div>
</div>
</Card>
{posMode === 'manual' ? (
<>
{/* Stats Grid */}
<StatsGrid
stats={stats}
columns={3}
/>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* Products Section */}
@@ -526,6 +932,181 @@ const POSPage: React.FC = () => {
</Card>
</div>
</div>
</>
) : (
/* Automatic POS Integration Section */
<div className="space-y-6">
<Card className="p-6">
<div className="flex items-center justify-between mb-6">
<div>
<h3 className="text-lg font-semibold text-[var(--text-primary)] flex items-center">
<Zap className="w-5 h-5 mr-2 text-yellow-500" />
Sistemas POS Integrados
</h3>
<p className="text-sm text-[var(--text-secondary)]">
Gestiona tus sistemas POS externos y configuraciones de integración
</p>
</div>
<Button
onClick={handleAddPosConfiguration}
className="flex items-center gap-2"
>
<Plus className="w-4 h-4" />
Agregar Sistema POS
</Button>
</div>
{posData.isLoading ? (
<div className="flex items-center justify-center h-32">
<Loader className="w-8 h-8 animate-spin" />
</div>
) : posData.configurations.length === 0 ? (
<div className="text-center py-8">
<div className="w-16 h-16 bg-gray-100 rounded-full flex items-center justify-center mx-auto mb-4">
<Zap className="w-8 h-8 text-gray-400" />
</div>
<h3 className="text-lg font-medium mb-2">No hay sistemas POS configurados</h3>
<p className="text-gray-500 mb-4">
Configura tu primer sistema POS para comenzar a sincronizar datos de ventas.
</p>
<Button onClick={handleAddPosConfiguration}>
Agregar Sistema POS
</Button>
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{posData.configurations.map(config => {
const provider = posData.supportedSystems.find(p => p.id === config.pos_system);
return (
<Card key={config.id} className="p-6">
<div className="flex items-start justify-between mb-4">
<div className="flex items-center">
<div className="text-2xl mr-3">📊</div>
<div>
<h3 className="font-medium">{config.provider_name}</h3>
<p className="text-sm text-gray-500">{provider?.name || config.pos_system}</p>
</div>
</div>
<div className="flex items-center space-x-1">
{config.is_connected ? (
<Wifi className="w-4 h-4 text-green-500" />
) : (
<WifiOff className="w-4 h-4 text-red-500" />
)}
<Badge variant={config.is_active ? 'success' : 'error'} size="sm">
{config.is_active ? 'Activo' : 'Inactivo'}
</Badge>
</div>
</div>
<div className="space-y-2 mb-4">
<div className="flex items-center justify-between text-sm">
<span>Estado de conexión:</span>
<div className="flex items-center">
{config.is_active ? (
<>
<CheckCircle className="w-4 h-4 text-green-500 mr-1" />
<span className="text-green-600">Conectado</span>
</>
) : (
<>
<AlertCircle className="w-4 h-4 text-yellow-500 mr-1" />
<span className="text-yellow-600">Desconectado</span>
</>
)}
</div>
</div>
{config.last_sync_at && (
<div className="flex justify-between text-sm text-gray-500">
<span>Última sincronización:</span>
<span>{new Date(config.last_sync_at).toLocaleString('es-ES')}</span>
</div>
)}
</div>
<div className="flex space-x-2">
<Button
variant="outline"
size="sm"
onClick={() => handleTestPosConnection(config.id)}
disabled={testingConnection === config.id}
className="flex-1"
>
{testingConnection === config.id ? (
<Loader className="w-4 h-4 animate-spin" />
) : (
<Wifi className="w-4 h-4" />
)}
Probar
</Button>
<Button
variant="outline"
size="sm"
onClick={() => handleEditPosConfiguration(config)}
className="flex-1"
>
<Settings className="w-4 h-4" />
Editar
</Button>
<Button
variant="outline"
size="sm"
onClick={() => handleDeletePosConfiguration(config.id)}
className="text-red-600 hover:text-red-700 border-red-200 hover:border-red-300"
>
<Trash2 className="w-4 h-4" />
</Button>
</div>
</Card>
);
})}
</div>
)}
</Card>
</div>
)}
{/* POS Configuration Modals */}
{/* Add Configuration Modal */}
<Modal
isOpen={showAddPosModal}
onClose={() => setShowAddPosModal(false)}
title="Agregar Sistema POS"
size="lg"
>
<div className="space-y-4">
{renderPosConfigurationForm()}
<div className="flex justify-end space-x-3 pt-4 border-t">
<Button variant="outline" onClick={() => setShowAddPosModal(false)}>
Cancelar
</Button>
<Button onClick={handleSavePosConfiguration}>
Guardar Configuración
</Button>
</div>
</div>
</Modal>
{/* Edit Configuration Modal */}
<Modal
isOpen={showEditPosModal}
onClose={() => setShowEditPosModal(false)}
title="Editar Sistema POS"
size="lg"
>
<div className="space-y-4">
{renderPosConfigurationForm()}
<div className="flex justify-end space-x-3 pt-4 border-t">
<Button variant="outline" onClick={() => setShowEditPosModal(false)}>
Cancelar
</Button>
<Button onClick={handleSavePosConfiguration}>
Actualizar Configuración
</Button>
</div>
</div>
</Modal>
</div>
);
};

View File

@@ -0,0 +1,50 @@
import React from 'react';
import { PageHeader } from '../../../../components/layout';
import { useAuthProfile, useUpdateProfile } from '../../../../api/hooks/auth';
import CommunicationPreferences, { type NotificationPreferences } from '../profile/CommunicationPreferences';
const CommunicationPreferencesPage: React.FC = () => {
const { data: profile } = useAuthProfile();
const updateProfileMutation = useUpdateProfile();
const [hasChanges, setHasChanges] = React.useState(false);
const handleSaveNotificationPreferences = async (preferences: NotificationPreferences) => {
try {
await updateProfileMutation.mutateAsync({
language: preferences.language,
timezone: preferences.timezone,
notification_preferences: preferences
});
setHasChanges(false);
} catch (error) {
throw error; // Let the component handle the error display
}
};
const handleResetNotificationPreferences = () => {
setHasChanges(false);
};
return (
<div className="p-6 space-y-6">
<PageHeader
title="Preferencias de Comunicación"
description="Gestiona tus preferencias de notificaciones y comunicación"
/>
<CommunicationPreferences
userEmail={profile?.email || ''}
userPhone={profile?.phone || ''}
userLanguage={profile?.language || 'es'}
userTimezone={profile?.timezone || 'Europe/Madrid'}
onSave={handleSaveNotificationPreferences}
onReset={handleResetNotificationPreferences}
hasChanges={hasChanges}
/>
</div>
);
};
export default CommunicationPreferencesPage;

View File

@@ -0,0 +1,393 @@
import React, { useState } from 'react';
import { User, Mail, Phone, Lock, Globe, Clock, Camera, Save, X } from 'lucide-react';
import { Button, Card, Avatar, Input, Select } from '../../../../components/ui';
import { PageHeader } from '../../../../components/layout';
import { useAuthUser } from '../../../../stores/auth.store';
import { useToast } from '../../../../hooks/ui/useToast';
import { useAuthProfile, useUpdateProfile, useChangePassword } from '../../../../api/hooks/auth';
import { useTranslation } from 'react-i18next';
interface ProfileFormData {
first_name: string;
last_name: string;
email: string;
phone: string;
language: string;
timezone: string;
avatar?: string;
}
interface PasswordData {
currentPassword: string;
newPassword: string;
confirmPassword: string;
}
const PersonalInfoPage: React.FC = () => {
const user = useAuthUser();
const { t } = useTranslation('auth');
const { addToast } = useToast();
const { data: profile, isLoading: profileLoading, error: profileError } = useAuthProfile();
const updateProfileMutation = useUpdateProfile();
const changePasswordMutation = useChangePassword();
const [isEditing, setIsEditing] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const [showPasswordForm, setShowPasswordForm] = useState(false);
const [profileData, setProfileData] = useState<ProfileFormData>({
first_name: '',
last_name: '',
email: '',
phone: '',
language: 'es',
timezone: 'Europe/Madrid'
});
// Update profile data when profile is loaded
React.useEffect(() => {
if (profile) {
setProfileData({
first_name: profile.first_name || '',
last_name: profile.last_name || '',
email: profile.email || '',
phone: profile.phone || '',
language: profile.language || 'es',
timezone: profile.timezone || 'Europe/Madrid',
avatar: profile.avatar || ''
});
}
}, [profile]);
const [passwordData, setPasswordData] = useState<PasswordData>({
currentPassword: '',
newPassword: '',
confirmPassword: ''
});
const [errors, setErrors] = useState<Record<string, string>>({});
const languageOptions = [
{ value: 'es', label: 'Español' },
{ value: 'ca', label: 'Català' },
{ value: 'en', label: 'English' }
];
const timezoneOptions = [
{ value: 'Europe/Madrid', label: 'Madrid (CET/CEST)' },
{ value: 'Atlantic/Canary', label: 'Canarias (WET/WEST)' },
{ value: 'Europe/London', label: 'Londres (GMT/BST)' }
];
const validateProfile = (): boolean => {
const newErrors: Record<string, string> = {};
if (!profileData.first_name.trim()) {
newErrors.first_name = 'El nombre es requerido';
}
if (!profileData.last_name.trim()) {
newErrors.last_name = 'Los apellidos son requeridos';
}
if (!profileData.email.trim()) {
newErrors.email = 'El email es requerido';
} else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(profileData.email)) {
newErrors.email = 'Email inválido';
}
setErrors(newErrors);
return Object.keys(newErrors).length === 0;
};
const validatePassword = (): boolean => {
const newErrors: Record<string, string> = {};
if (!passwordData.currentPassword) {
newErrors.currentPassword = 'Contraseña actual requerida';
}
if (!passwordData.newPassword) {
newErrors.newPassword = 'Nueva contraseña requerida';
} else if (passwordData.newPassword.length < 8) {
newErrors.newPassword = 'Mínimo 8 caracteres';
}
if (passwordData.newPassword !== passwordData.confirmPassword) {
newErrors.confirmPassword = 'Las contraseñas no coinciden';
}
setErrors(newErrors);
return Object.keys(newErrors).length === 0;
};
const handleSaveProfile = async () => {
if (!validateProfile()) return;
setIsLoading(true);
try {
await updateProfileMutation.mutateAsync(profileData);
setIsEditing(false);
addToast('Perfil actualizado correctamente', 'success');
} catch (error) {
addToast('No se pudo actualizar tu perfil', 'error');
} finally {
setIsLoading(false);
}
};
const handleChangePasswordSubmit = async () => {
if (!validatePassword()) return;
setIsLoading(true);
try {
await changePasswordMutation.mutateAsync({
current_password: passwordData.currentPassword,
new_password: passwordData.newPassword,
confirm_password: passwordData.confirmPassword
});
setShowPasswordForm(false);
setPasswordData({ currentPassword: '', newPassword: '', confirmPassword: '' });
addToast('Contraseña actualizada correctamente', 'success');
} catch (error) {
addToast('No se pudo cambiar tu contraseña', 'error');
} finally {
setIsLoading(false);
}
};
const handleInputChange = (field: keyof ProfileFormData) => (e: React.ChangeEvent<HTMLInputElement>) => {
setProfileData(prev => ({ ...prev, [field]: e.target.value }));
if (errors[field]) {
setErrors(prev => ({ ...prev, [field]: '' }));
}
};
const handleSelectChange = (field: keyof ProfileFormData) => (value: string) => {
setProfileData(prev => ({ ...prev, [field]: value }));
};
const handlePasswordChange = (field: keyof PasswordData) => (e: React.ChangeEvent<HTMLInputElement>) => {
setPasswordData(prev => ({ ...prev, [field]: e.target.value }));
if (errors[field]) {
setErrors(prev => ({ ...prev, [field]: '' }));
}
};
return (
<div className="p-6 space-y-6">
<PageHeader
title="Información Personal"
description="Gestiona tu información personal y datos de contacto"
/>
{/* Profile Header */}
<Card className="p-6">
<div className="flex items-center gap-6">
<div className="relative">
<Avatar
src={profile?.avatar || undefined}
alt={profile?.full_name || `${profileData.first_name} ${profileData.last_name}` || 'Usuario'}
name={profile?.avatar ? (profile?.full_name || `${profileData.first_name} ${profileData.last_name}`) : undefined}
size="xl"
className="w-20 h-20"
/>
<button className="absolute -bottom-1 -right-1 bg-color-primary text-white rounded-full p-2 shadow-lg hover:bg-color-primary-dark transition-colors">
<Camera className="w-4 h-4" />
</button>
</div>
<div className="flex-1">
<h1 className="text-2xl font-bold text-text-primary mb-1">
{profileData.first_name} {profileData.last_name}
</h1>
<p className="text-text-secondary">{profileData.email}</p>
{user?.role && (
<p className="text-sm text-text-tertiary mt-1">
{t(`global_roles.${user.role}`)}
</p>
)}
<div className="flex items-center gap-2 mt-2">
<div className="w-2 h-2 bg-green-500 rounded-full"></div>
<span className="text-sm text-text-tertiary">En línea</span>
</div>
</div>
<div className="flex gap-2">
{!isEditing && (
<Button
variant="outline"
onClick={() => setIsEditing(true)}
className="flex items-center gap-2"
>
<User className="w-4 h-4" />
Editar Perfil
</Button>
)}
<Button
variant="outline"
onClick={() => setShowPasswordForm(!showPasswordForm)}
className="flex items-center gap-2"
>
<Lock className="w-4 h-4" />
Cambiar Contraseña
</Button>
</div>
</div>
</Card>
{/* Profile Form */}
<Card className="p-6">
<h2 className="text-lg font-semibold mb-4">Información Personal</h2>
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-6">
<Input
label="Nombre"
value={profileData.first_name}
onChange={handleInputChange('first_name')}
error={errors.first_name}
disabled={!isEditing || isLoading}
leftIcon={<User className="w-4 h-4" />}
/>
<Input
label="Apellidos"
value={profileData.last_name}
onChange={handleInputChange('last_name')}
error={errors.last_name}
disabled={!isEditing || isLoading}
/>
<Input
type="email"
label="Correo Electrónico"
value={profileData.email}
onChange={handleInputChange('email')}
error={errors.email}
disabled={!isEditing || isLoading}
leftIcon={<Mail className="w-4 h-4" />}
/>
<Input
type="tel"
label="Teléfono"
value={profileData.phone}
onChange={handleInputChange('phone')}
error={errors.phone}
disabled={!isEditing || isLoading}
placeholder="+34 600 000 000"
leftIcon={<Phone className="w-4 h-4" />}
/>
<Select
label="Idioma"
options={languageOptions}
value={profileData.language}
onChange={handleSelectChange('language')}
disabled={!isEditing || isLoading}
leftIcon={<Globe className="w-4 h-4" />}
/>
<Select
label="Zona Horaria"
options={timezoneOptions}
value={profileData.timezone}
onChange={handleSelectChange('timezone')}
disabled={!isEditing || isLoading}
leftIcon={<Clock className="w-4 h-4" />}
/>
</div>
{isEditing && (
<div className="flex gap-3 mt-6 pt-4 border-t">
<Button
variant="outline"
onClick={() => setIsEditing(false)}
disabled={isLoading}
className="flex items-center gap-2"
>
<X className="w-4 h-4" />
Cancelar
</Button>
<Button
variant="primary"
onClick={handleSaveProfile}
isLoading={isLoading}
loadingText="Guardando..."
className="flex items-center gap-2"
>
<Save className="w-4 h-4" />
Guardar Cambios
</Button>
</div>
)}
</Card>
{/* Password Change Form */}
{showPasswordForm && (
<Card className="p-6">
<h2 className="text-lg font-semibold mb-4">Cambiar Contraseña</h2>
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-6 max-w-4xl">
<Input
type="password"
label="Contraseña Actual"
value={passwordData.currentPassword}
onChange={handlePasswordChange('currentPassword')}
error={errors.currentPassword}
disabled={isLoading}
leftIcon={<Lock className="w-4 h-4" />}
/>
<Input
type="password"
label="Nueva Contraseña"
value={passwordData.newPassword}
onChange={handlePasswordChange('newPassword')}
error={errors.newPassword}
disabled={isLoading}
leftIcon={<Lock className="w-4 h-4" />}
/>
<Input
type="password"
label="Confirmar Nueva Contraseña"
value={passwordData.confirmPassword}
onChange={handlePasswordChange('confirmPassword')}
error={errors.confirmPassword}
disabled={isLoading}
leftIcon={<Lock className="w-4 h-4" />}
/>
</div>
<div className="flex gap-3 pt-6 mt-6 border-t">
<Button
variant="outline"
onClick={() => {
setShowPasswordForm(false);
setPasswordData({ currentPassword: '', newPassword: '', confirmPassword: '' });
setErrors({});
}}
disabled={isLoading}
>
Cancelar
</Button>
<Button
variant="primary"
onClick={handleChangePasswordSubmit}
isLoading={isLoading}
loadingText="Cambiando..."
>
Cambiar Contraseña
</Button>
</div>
</Card>
)}
</div>
);
};
export default PersonalInfoPage;

View File

@@ -0,0 +1,476 @@
import React, { useState } from 'react';
import { Crown, Users, MapPin, Package, TrendingUp, RefreshCw, AlertCircle, CheckCircle, ArrowRight, Star, ExternalLink, Download } from 'lucide-react';
import { Button, Card, Badge, Modal } from '../../../../components/ui';
import { PageHeader } from '../../../../components/layout';
import { useAuthUser } from '../../../../stores/auth.store';
import { useCurrentTenant } from '../../../../stores';
import { useToast } from '../../../../hooks/ui/useToast';
import { subscriptionService, type UsageSummary, type AvailablePlans } from '../../../../api';
const SubscriptionPage: React.FC = () => {
const user = useAuthUser();
const currentTenant = useCurrentTenant();
const { addToast } = useToast();
const [usageSummary, setUsageSummary] = useState<UsageSummary | null>(null);
const [availablePlans, setAvailablePlans] = useState<AvailablePlans | null>(null);
const [subscriptionLoading, setSubscriptionLoading] = useState(false);
const [upgradeDialogOpen, setUpgradeDialogOpen] = useState(false);
const [selectedPlan, setSelectedPlan] = useState<string>('');
const [upgrading, setUpgrading] = useState(false);
// Load subscription data on component mount
React.useEffect(() => {
loadSubscriptionData();
}, []);
const loadSubscriptionData = async () => {
const tenantId = currentTenant?.id || user?.tenant_id;
if (!tenantId) {
addToast('No se encontró información del tenant', { type: 'error' });
return;
}
try {
setSubscriptionLoading(true);
const [usage, plans] = await Promise.all([
subscriptionService.getUsageSummary(tenantId),
subscriptionService.getAvailablePlans()
]);
setUsageSummary(usage);
setAvailablePlans(plans);
} catch (error) {
console.error('Error loading subscription data:', error);
addToast("No se pudo cargar la información de suscripción", { type: 'error' });
} finally {
setSubscriptionLoading(false);
}
};
const handleUpgradeClick = (planKey: string) => {
setSelectedPlan(planKey);
setUpgradeDialogOpen(true);
};
const handleUpgradeConfirm = async () => {
const tenantId = currentTenant?.id || user?.tenant_id;
if (!tenantId || !selectedPlan) {
addToast('Información de tenant no disponible', { type: 'error' });
return;
}
try {
setUpgrading(true);
const validation = await subscriptionService.validatePlanUpgrade(
tenantId,
selectedPlan
);
if (!validation.can_upgrade) {
addToast(validation.reason || 'No se puede actualizar el plan', { type: 'error' });
return;
}
const result = await subscriptionService.upgradePlan(tenantId, selectedPlan);
if (result.success) {
addToast(result.message, { type: 'success' });
await loadSubscriptionData();
setUpgradeDialogOpen(false);
setSelectedPlan('');
} else {
addToast('Error al cambiar el plan', { type: 'error' });
}
} catch (error) {
console.error('Error upgrading plan:', error);
addToast('Error al procesar el cambio de plan', { type: 'error' });
} finally {
setUpgrading(false);
}
};
const ProgressBar: React.FC<{ value: number; className?: string }> = ({ value, className = '' }) => {
const getProgressColor = () => {
if (value >= 90) return 'bg-red-500';
if (value >= 80) return 'bg-yellow-500';
return 'bg-green-500';
};
return (
<div className={`w-full bg-[var(--bg-tertiary)] border border-[var(--border-secondary)] rounded-full h-3 ${className}`}>
<div
className={`${getProgressColor()} h-full rounded-full transition-all duration-500 relative`}
style={{ width: `${Math.min(100, Math.max(0, value))}%` }}
>
<div className="absolute inset-0 bg-gradient-to-r from-transparent to-white/20 rounded-full"></div>
</div>
</div>
);
};
return (
<div className="p-6 space-y-6">
<PageHeader
title="Suscripción y Facturación"
description="Gestiona tu suscripción, uso de recursos y facturación"
/>
{subscriptionLoading ? (
<div className="flex items-center justify-center min-h-[400px]">
<div className="flex flex-col items-center gap-4">
<RefreshCw className="w-8 h-8 animate-spin text-[var(--color-primary)]" />
<p className="text-[var(--text-secondary)]">Cargando información de suscripción...</p>
</div>
</div>
) : !usageSummary || !availablePlans ? (
<div className="flex items-center justify-center min-h-[400px]">
<div className="flex flex-col items-center gap-4">
<AlertCircle className="w-12 h-12 text-[var(--text-tertiary)]" />
<div className="text-center">
<h3 className="text-lg font-semibold text-[var(--text-primary)] mb-2">No se pudo cargar la información</h3>
<p className="text-[var(--text-secondary)] mb-4">Hubo un problema al cargar los datos de suscripción</p>
<Button onClick={loadSubscriptionData} variant="primary">
<RefreshCw className="w-4 h-4 mr-2" />
Reintentar
</Button>
</div>
</div>
</div>
) : (
<>
{/* Current Plan Overview */}
<Card className="p-6">
<div className="flex items-center justify-between mb-6">
<h3 className="text-lg font-semibold text-[var(--text-primary)] flex items-center">
<Crown className="w-5 h-5 mr-2 text-yellow-500" />
Plan Actual: {subscriptionService.getPlanDisplayInfo(usageSummary.plan).name}
</h3>
<Badge
variant={usageSummary.status === 'active' ? 'success' : 'default'}
className="text-sm font-medium"
>
{usageSummary.status === 'active' ? 'Activo' : usageSummary.status}
</Badge>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 mb-6">
<div className="p-4 bg-[var(--bg-secondary)] rounded-lg">
<div className="flex items-center justify-between">
<span className="text-sm text-[var(--text-secondary)]">Precio Mensual</span>
<span className="font-semibold text-[var(--text-primary)]">{subscriptionService.formatPrice(usageSummary.monthly_price)}</span>
</div>
</div>
<div className="p-4 bg-[var(--bg-secondary)] rounded-lg">
<div className="flex items-center justify-between">
<span className="text-sm text-[var(--text-secondary)]">Próxima Facturación</span>
<span className="font-medium text-[var(--text-primary)]">
{new Date(usageSummary.next_billing_date).toLocaleDateString('es-ES', { day: '2-digit', month: '2-digit' })}
</span>
</div>
</div>
<div className="p-4 bg-[var(--bg-secondary)] rounded-lg">
<div className="flex items-center justify-between">
<span className="text-sm text-[var(--text-secondary)]">Usuarios</span>
<span className="font-medium text-[var(--text-primary)]">
{usageSummary.usage.users.current}/{usageSummary.usage.users.unlimited ? '∞' : usageSummary.usage.users.limit}
</span>
</div>
</div>
<div className="p-4 bg-[var(--bg-secondary)] rounded-lg">
<div className="flex items-center justify-between">
<span className="text-sm text-[var(--text-secondary)]">Ubicaciones</span>
<span className="font-medium text-[var(--text-primary)]">
{usageSummary.usage.locations.current}/{usageSummary.usage.locations.unlimited ? '∞' : usageSummary.usage.locations.limit}
</span>
</div>
</div>
</div>
<div className="flex flex-wrap gap-2">
<Button variant="outline" onClick={() => window.open('https://billing.bakery.com', '_blank')} className="flex items-center gap-2">
<ExternalLink className="w-4 h-4" />
Portal de Facturación
</Button>
<Button variant="outline" onClick={() => console.log('Download invoice')} className="flex items-center gap-2">
<Download className="w-4 h-4" />
Descargar Facturas
</Button>
<Button variant="outline" onClick={loadSubscriptionData} className="flex items-center gap-2">
<RefreshCw className="w-4 h-4" />
Actualizar
</Button>
</div>
</Card>
{/* Usage Details */}
<Card className="p-6">
<h3 className="text-lg font-semibold mb-6 text-[var(--text-primary)] flex items-center">
<TrendingUp className="w-5 h-5 mr-2 text-orange-500" />
Uso de Recursos
</h3>
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
{/* Users */}
<div className="space-y-4 p-4 bg-[var(--bg-secondary)] border border-[var(--border-secondary)] rounded-lg">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="p-2 bg-blue-500/10 rounded-lg border border-blue-500/20">
<Users className="w-4 h-4 text-blue-500" />
</div>
<span className="font-medium text-[var(--text-primary)]">Usuarios</span>
</div>
<span className="text-sm font-bold text-[var(--text-primary)]">
{usageSummary.usage.users.current}<span className="text-[var(--text-tertiary)]">/</span>
<span className="text-[var(--text-tertiary)]">{usageSummary.usage.users.unlimited ? '∞' : usageSummary.usage.users.limit}</span>
</span>
</div>
<ProgressBar value={usageSummary.usage.users.usage_percentage} />
<p className="text-xs text-[var(--text-secondary)] flex items-center justify-between">
<span>{usageSummary.usage.users.usage_percentage}% utilizado</span>
<span className="font-medium">{usageSummary.usage.users.unlimited ? 'Ilimitado' : `${usageSummary.usage.users.limit - usageSummary.usage.users.current} restantes`}</span>
</p>
</div>
{/* Locations */}
<div className="space-y-4 p-4 bg-[var(--bg-secondary)] border border-[var(--border-secondary)] rounded-lg">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="p-2 bg-green-500/10 rounded-lg border border-green-500/20">
<MapPin className="w-4 h-4 text-green-500" />
</div>
<span className="font-medium text-[var(--text-primary)]">Ubicaciones</span>
</div>
<span className="text-sm font-bold text-[var(--text-primary)]">
{usageSummary.usage.locations.current}<span className="text-[var(--text-tertiary)]">/</span>
<span className="text-[var(--text-tertiary)]">{usageSummary.usage.locations.unlimited ? '∞' : usageSummary.usage.locations.limit}</span>
</span>
</div>
<ProgressBar value={usageSummary.usage.locations.usage_percentage} />
<p className="text-xs text-[var(--text-secondary)] flex items-center justify-between">
<span>{usageSummary.usage.locations.usage_percentage}% utilizado</span>
<span className="font-medium">{usageSummary.usage.locations.unlimited ? 'Ilimitado' : `${usageSummary.usage.locations.limit - usageSummary.usage.locations.current} restantes`}</span>
</p>
</div>
{/* Products */}
<div className="space-y-4 p-4 bg-[var(--bg-secondary)] border border-[var(--border-secondary)] rounded-lg">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="p-2 bg-purple-500/10 rounded-lg border border-purple-500/20">
<Package className="w-4 h-4 text-purple-500" />
</div>
<span className="font-medium text-[var(--text-primary)]">Productos</span>
</div>
<span className="text-sm font-bold text-[var(--text-primary)]">
{usageSummary.usage.products.current}<span className="text-[var(--text-tertiary)]">/</span>
<span className="text-[var(--text-tertiary)]">{usageSummary.usage.products.unlimited ? '∞' : usageSummary.usage.products.limit}</span>
</span>
</div>
<ProgressBar value={usageSummary.usage.products.usage_percentage} />
<p className="text-xs text-[var(--text-secondary)] flex items-center justify-between">
<span>{usageSummary.usage.products.usage_percentage}% utilizado</span>
<span className="font-medium">{usageSummary.usage.products.unlimited ? 'Ilimitado' : 'Ilimitado'}</span>
</p>
</div>
</div>
</Card>
{/* Available Plans */}
<Card className="p-6">
<h3 className="text-lg font-semibold mb-6 text-[var(--text-primary)] flex items-center">
<Crown className="w-5 h-5 mr-2 text-yellow-500" />
Planes Disponibles
</h3>
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
{Object.entries(availablePlans.plans).map(([planKey, plan]) => {
const isCurrentPlan = usageSummary.plan === planKey;
const getPlanColor = () => {
switch (planKey) {
case 'starter': return 'border-blue-500/30 bg-blue-500/5';
case 'professional': return 'border-purple-500/30 bg-purple-500/5';
case 'enterprise': return 'border-amber-500/30 bg-amber-500/5';
default: return 'border-[var(--border-primary)] bg-[var(--bg-secondary)]';
}
};
return (
<Card
key={planKey}
className={`relative p-6 ${getPlanColor()} ${
isCurrentPlan ? 'ring-2 ring-[var(--color-primary)]' : ''
}`}
>
{plan.popular && (
<div className="absolute -top-3 left-1/2 transform -translate-x-1/2">
<Badge variant="primary" className="px-3 py-1">
<Star className="w-3 h-3 mr-1" />
Más Popular
</Badge>
</div>
)}
<div className="text-center mb-6">
<h4 className="text-xl font-bold text-[var(--text-primary)] mb-2">{plan.name}</h4>
<div className="text-3xl font-bold text-[var(--color-primary)] mb-1">
{subscriptionService.formatPrice(plan.monthly_price)}
<span className="text-lg text-[var(--text-secondary)]">/mes</span>
</div>
<p className="text-sm text-[var(--text-secondary)]">{plan.description}</p>
</div>
<div className="space-y-3 mb-6">
<div className="flex items-center gap-2 text-sm">
<Users className="w-4 h-4 text-[var(--color-primary)]" />
<span>{plan.max_users === -1 ? 'Usuarios ilimitados' : `${plan.max_users} usuarios`}</span>
</div>
<div className="flex items-center gap-2 text-sm">
<MapPin className="w-4 h-4 text-[var(--color-primary)]" />
<span>{plan.max_locations === -1 ? 'Ubicaciones ilimitadas' : `${plan.max_locations} ubicación${plan.max_locations > 1 ? 'es' : ''}`}</span>
</div>
<div className="flex items-center gap-2 text-sm">
<Package className="w-4 h-4 text-[var(--color-primary)]" />
<span>{plan.max_products === -1 ? 'Productos ilimitados' : `${plan.max_products} productos`}</span>
</div>
</div>
{/* Features Section */}
<div className="border-t border-[var(--border-color)] pt-4 mb-6">
<h5 className="text-sm font-semibold text-[var(--text-primary)] mb-3 flex items-center">
<TrendingUp className="w-4 h-4 mr-2 text-[var(--color-primary)]" />
Funcionalidades Incluidas
</h5>
<div className="space-y-2">
{(() => {
const getPlanFeatures = (planKey: string) => {
switch (planKey) {
case 'starter':
return [
'✓ Panel de Control Básico',
'✓ Gestión de Inventario',
'✓ Gestión de Pedidos',
'✓ Gestión de Proveedores',
'✓ Punto de Venta Básico',
'✗ Analytics Avanzados',
'✗ Pronósticos IA',
'✗ Insights Predictivos'
];
case 'professional':
return [
'✓ Panel de Control Avanzado',
'✓ Gestión de Inventario Completa',
'✓ Analytics de Ventas',
'✓ Pronósticos con IA (92% precisión)',
'✓ Análisis de Rendimiento',
'✓ Optimización de Producción',
'✓ Integración POS',
'✗ Insights Predictivos Avanzados'
];
case 'enterprise':
return [
'✓ Todas las funcionalidades Professional',
'✓ Insights Predictivos con IA',
'✓ Analytics Multi-ubicación',
'✓ Integración ERP',
'✓ API Personalizada',
'✓ Gestor de Cuenta Dedicado',
'✓ Soporte 24/7 Prioritario',
'✓ Demo Personalizada'
];
default:
return [];
}
};
return getPlanFeatures(planKey).map((feature, index) => (
<div key={index} className={`text-xs flex items-center gap-2 ${
feature.startsWith('✓')
? 'text-green-600'
: 'text-[var(--text-secondary)] opacity-60'
}`}>
<span>{feature}</span>
</div>
));
})()}
</div>
</div>
{isCurrentPlan ? (
<Badge variant="success" className="w-full justify-center py-2">
<CheckCircle className="w-4 h-4 mr-2" />
Plan Actual
</Badge>
) : (
<Button
variant={plan.popular ? 'primary' : 'outline'}
className="w-full"
onClick={() => handleUpgradeClick(planKey)}
>
{plan.contact_sales ? 'Contactar Ventas' : 'Cambiar Plan'}
<ArrowRight className="w-4 h-4 ml-2" />
</Button>
)}
</Card>
);
})}
</div>
</Card>
</>
)}
{/* Upgrade Modal */}
{upgradeDialogOpen && selectedPlan && availablePlans && (
<Modal
isOpen={upgradeDialogOpen}
onClose={() => setUpgradeDialogOpen(false)}
title="Confirmar Cambio de Plan"
>
<div className="space-y-4">
<p className="text-[var(--text-secondary)]">
¿Estás seguro de que quieres cambiar tu plan de suscripción?
</p>
{availablePlans.plans[selectedPlan] && usageSummary && (
<div className="p-4 bg-[var(--bg-secondary)] rounded-lg space-y-2">
<div className="flex justify-between">
<span>Plan actual:</span>
<span>{subscriptionService.getPlanDisplayInfo(usageSummary.plan).name}</span>
</div>
<div className="flex justify-between">
<span>Nuevo plan:</span>
<span>{availablePlans.plans[selectedPlan].name}</span>
</div>
<div className="flex justify-between font-medium">
<span>Nuevo precio:</span>
<span>{subscriptionService.formatPrice(availablePlans.plans[selectedPlan].monthly_price)}/mes</span>
</div>
</div>
)}
<div className="flex gap-2 pt-4">
<Button
variant="outline"
onClick={() => setUpgradeDialogOpen(false)}
className="flex-1"
>
Cancelar
</Button>
<Button
variant="primary"
onClick={handleUpgradeConfirm}
disabled={upgrading}
className="flex-1"
>
{upgrading ? 'Procesando...' : 'Confirmar Cambio'}
</Button>
</div>
</div>
</Modal>
)}
</div>
);
};
export default SubscriptionPage;

View File

@@ -29,8 +29,10 @@ const PerformanceAnalyticsPage = React.lazy(() => import('../pages/app/analytics
// Settings pages
const ProfilePage = React.lazy(() => import('../pages/app/settings/profile/ProfilePage'));
const BakeryConfigPage = React.lazy(() => import('../pages/app/settings/bakery-config/BakeryConfigPage'));
const PersonalInfoPage = React.lazy(() => import('../pages/app/settings/personal-info/PersonalInfoPage'));
const CommunicationPreferencesPage = React.lazy(() => import('../pages/app/settings/communication-preferences/CommunicationPreferencesPage'));
const SubscriptionPage = React.lazy(() => import('../pages/app/settings/subscription/SubscriptionPage'));
const InformationPage = React.lazy(() => import('../pages/app/database/information/InformationPage'));
const TeamPage = React.lazy(() => import('../pages/app/settings/team/TeamPage'));
const OrganizationsPage = React.lazy(() => import('../pages/app/settings/organizations/OrganizationsPage'));
@@ -162,11 +164,11 @@ export const AppRouter: React.FC = () => {
}
/>
<Route
path="/app/database/bakery-config"
path="/app/database/information"
element={
<ProtectedRoute>
<AppShell>
<BakeryConfigPage />
<InformationPage />
</AppShell>
</ProtectedRoute>
}
@@ -267,11 +269,31 @@ export const AppRouter: React.FC = () => {
{/* Settings Routes */}
<Route
path="/app/settings/profile"
path="/app/settings/personal-info"
element={
<ProtectedRoute>
<AppShell>
<ProfilePage />
<PersonalInfoPage />
</AppShell>
</ProtectedRoute>
}
/>
<Route
path="/app/settings/communication-preferences"
element={
<ProtectedRoute>
<AppShell>
<CommunicationPreferencesPage />
</AppShell>
</ProtectedRoute>
}
/>
<Route
path="/app/settings/subscription"
element={
<ProtectedRoute>
<AppShell>
<SubscriptionPage />
</AppShell>
</ProtectedRoute>
}

View File

@@ -129,13 +129,16 @@ export const ROUTES = {
// Settings
SETTINGS: '/settings',
SETTINGS_PROFILE: '/app/settings/profile',
SETTINGS_PROFILE: '/app/settings/personal-info',
SETTINGS_COMMUNICATION: '/app/settings/communication-preferences',
SETTINGS_SUBSCRIPTION: '/app/settings/subscription',
SETTINGS_ORGANIZATIONS: '/app/settings/organizations',
SETTINGS_TENANT: '/settings/tenant',
SETTINGS_USERS: '/settings/users',
SETTINGS_PERMISSIONS: '/settings/permissions',
SETTINGS_INTEGRATIONS: '/settings/integrations',
SETTINGS_BILLING: '/settings/billing',
SETTINGS_BAKERY_CONFIG: '/app/database/bakery-config',
SETTINGS_BAKERY_CONFIG: '/app/database/information',
SETTINGS_TEAM: '/app/database/team',
QUALITY_TEMPLATES: '/app/database/quality-templates',
@@ -262,112 +265,6 @@ export const routesConfig: RouteConfig[] = [
],
},
// Catalog Section - Current Bakery Status
{
path: '/app/database',
name: 'Database',
component: 'DatabasePage',
title: 'Mi Panadería',
icon: 'database',
requiresAuth: true,
showInNavigation: true,
children: [
{
path: '/app/database/recipes',
name: 'Recipes',
component: 'RecipesPage',
title: 'Recetas',
icon: 'production',
requiresAuth: true,
showInNavigation: true,
showInBreadcrumbs: true,
},
{
path: '/app/database/orders',
name: 'Orders',
component: 'OrdersPage',
title: 'Pedidos',
icon: 'orders',
requiresAuth: true,
showInNavigation: true,
showInBreadcrumbs: true,
},
{
path: '/app/database/suppliers',
name: 'Suppliers',
component: 'SuppliersPage',
title: 'Proveedores',
icon: 'procurement',
requiresAuth: true,
showInNavigation: true,
showInBreadcrumbs: true,
},
{
path: '/app/database/inventory',
name: 'Inventory',
component: 'InventoryPage',
title: 'Inventario',
icon: 'inventory',
requiresAuth: true,
showInNavigation: true,
showInBreadcrumbs: true,
},
{
path: '/app/database/maquinaria',
name: 'Maquinaria',
component: 'MaquinariaPage',
title: 'Maquinaria',
icon: 'production',
requiresAuth: true,
showInNavigation: true,
showInBreadcrumbs: true,
},
{
path: '/app/database/bakery-config',
name: 'BakeryConfig',
component: 'BakeryConfigPage',
title: 'Configuración de Panadería',
icon: 'settings',
requiresAuth: true,
requiredRoles: ROLE_COMBINATIONS.ADMIN_ACCESS,
showInNavigation: true,
showInBreadcrumbs: true,
},
{
path: '/app/database/team',
name: 'Team',
component: 'TeamPage',
title: 'Gestión de Equipo',
icon: 'user',
requiresAuth: true,
requiredRoles: ROLE_COMBINATIONS.ADMIN_ACCESS,
showInNavigation: true,
showInBreadcrumbs: true,
},
{
path: '/app/database/models',
name: 'ModelsConfig',
component: 'ModelsConfigPage',
title: 'Modelos IA',
icon: 'training',
requiresAuth: true,
showInNavigation: true,
showInBreadcrumbs: true,
},
{
path: '/app/database/quality-templates',
name: 'QualityTemplates',
component: 'QualityTemplatesPage',
title: 'Plantillas de Calidad',
icon: 'settings',
requiresAuth: true,
requiredRoles: ROLE_COMBINATIONS.MANAGEMENT_ACCESS,
showInNavigation: true,
showInBreadcrumbs: true,
},
],
},
// Analytics Section - Subscription protected
{
path: '/app/analytics',
@@ -443,26 +340,152 @@ export const routesConfig: RouteConfig[] = [
],
},
// Catalog Section - Current Bakery Status
{
path: '/app/database',
name: 'Database',
component: 'DatabasePage',
title: 'Mi Panadería',
icon: 'database',
requiresAuth: true,
showInNavigation: true,
children: [
{
path: '/app/database/recipes',
name: 'Recipes',
component: 'RecipesPage',
title: 'Recetas',
icon: 'production',
requiresAuth: true,
showInNavigation: true,
showInBreadcrumbs: true,
},
{
path: '/app/database/orders',
name: 'Orders',
component: 'OrdersPage',
title: 'Pedidos',
icon: 'orders',
requiresAuth: true,
showInNavigation: true,
showInBreadcrumbs: true,
},
{
path: '/app/database/suppliers',
name: 'Suppliers',
component: 'SuppliersPage',
title: 'Proveedores',
icon: 'procurement',
requiresAuth: true,
showInNavigation: true,
showInBreadcrumbs: true,
},
{
path: '/app/database/inventory',
name: 'Inventory',
component: 'InventoryPage',
title: 'Inventario',
icon: 'inventory',
requiresAuth: true,
showInNavigation: true,
showInBreadcrumbs: true,
},
{
path: '/app/database/maquinaria',
name: 'Maquinaria',
component: 'MaquinariaPage',
title: 'Maquinaria',
icon: 'production',
requiresAuth: true,
showInNavigation: true,
showInBreadcrumbs: true,
},
{
path: '/app/database/information',
name: 'Information',
component: 'InformationPage',
title: 'Información',
icon: 'settings',
requiresAuth: true,
requiredRoles: ROLE_COMBINATIONS.ADMIN_ACCESS,
showInNavigation: true,
showInBreadcrumbs: true,
},
{
path: '/app/database/team',
name: 'Team',
component: 'TeamPage',
title: 'Gestión de Equipo',
icon: 'user',
requiresAuth: true,
requiredRoles: ROLE_COMBINATIONS.ADMIN_ACCESS,
showInNavigation: true,
showInBreadcrumbs: true,
},
{
path: '/app/database/models',
name: 'ModelsConfig',
component: 'ModelsConfigPage',
title: 'Modelos IA',
icon: 'training',
requiresAuth: true,
showInNavigation: true,
showInBreadcrumbs: true,
},
{
path: '/app/database/quality-templates',
name: 'QualityTemplates',
component: 'QualityTemplatesPage',
title: 'Plantillas de Calidad',
icon: 'settings',
requiresAuth: true,
requiredRoles: ROLE_COMBINATIONS.MANAGEMENT_ACCESS,
showInNavigation: true,
showInBreadcrumbs: true,
},
],
},
// Settings Section
{
path: '/app/settings',
name: 'Settings',
component: 'SettingsPage',
title: 'Configuración',
icon: 'settings',
title: 'Mi Perfil',
icon: 'user',
requiresAuth: true,
showInNavigation: true,
children: [
{
path: '/app/settings/profile',
name: 'Profile',
component: 'ProfilePage',
title: 'Mi Perfil',
path: '/app/settings/personal-info',
name: 'PersonalInfo',
component: 'PersonalInfoPage',
title: 'Información Personal',
icon: 'user',
requiresAuth: true,
showInNavigation: true,
showInBreadcrumbs: true,
},
{
path: '/app/settings/communication-preferences',
name: 'CommunicationPreferences',
component: 'CommunicationPreferencesPage',
title: 'Preferencias de Comunicación',
icon: 'bell',
requiresAuth: true,
showInNavigation: true,
showInBreadcrumbs: true,
},
{
path: '/app/settings/subscription',
name: 'Subscription',
component: 'SubscriptionPage',
title: 'Suscripción',
icon: 'credit-card',
requiresAuth: true,
showInNavigation: true,
showInBreadcrumbs: true,
},
{
path: '/app/settings/organizations',
name: 'Organizations',