Add i18 support
This commit is contained in:
@@ -1,17 +1,18 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import { Button } from '../../ui/Button';
|
||||
import { Card, CardHeader, CardBody } from '../../ui/Card';
|
||||
import { useAuth } from '../../../contexts/AuthContext';
|
||||
import { useUserProgress, useMarkStepCompleted } from '../../../api/hooks/onboarding';
|
||||
import { useTenantActions } from '../../../stores/tenant.store';
|
||||
import { useTenantInitializer } from '../../../stores/useTenantInitializer';
|
||||
import {
|
||||
import {
|
||||
RegisterTenantStep,
|
||||
UploadSalesDataStep,
|
||||
MLTrainingStep,
|
||||
CompletionStep
|
||||
} from './steps';
|
||||
import { Building2 } from 'lucide-react';
|
||||
|
||||
interface StepConfig {
|
||||
id: string;
|
||||
@@ -58,11 +59,26 @@ const STEPS: StepConfig[] = [
|
||||
];
|
||||
|
||||
export const OnboardingWizard: React.FC = () => {
|
||||
const [currentStepIndex, setCurrentStepIndex] = useState(0);
|
||||
const [isInitialized, setIsInitialized] = useState(false);
|
||||
const [searchParams] = useSearchParams();
|
||||
const navigate = useNavigate();
|
||||
const { user } = useAuth();
|
||||
|
||||
|
||||
// Check if this is a fresh onboarding (new tenant creation)
|
||||
const isNewTenant = searchParams.get('new') === 'true';
|
||||
|
||||
// Initialize state based on whether this is a new tenant or not
|
||||
const [currentStepIndex, setCurrentStepIndex] = useState(0);
|
||||
const [isInitialized, setIsInitialized] = useState(isNewTenant); // If new tenant, consider initialized immediately
|
||||
|
||||
// Debug log for new tenant creation
|
||||
useEffect(() => {
|
||||
if (isNewTenant) {
|
||||
console.log('🆕 New tenant creation detected - UI will reset to step 0');
|
||||
console.log('📊 Current step index:', currentStepIndex);
|
||||
console.log('🎯 Is initialized:', isInitialized);
|
||||
}
|
||||
}, [isNewTenant, currentStepIndex, isInitialized]);
|
||||
|
||||
// Initialize tenant data for authenticated users
|
||||
useTenantInitializer();
|
||||
|
||||
@@ -106,64 +122,77 @@ export const OnboardingWizard: React.FC = () => {
|
||||
|
||||
// Initialize step index based on backend progress with validation
|
||||
useEffect(() => {
|
||||
// Skip backend progress loading for new tenant creation
|
||||
if (isNewTenant) {
|
||||
return; // Already initialized to step 0
|
||||
}
|
||||
|
||||
if (userProgress && !isInitialized) {
|
||||
console.log('🔄 Initializing onboarding progress:', userProgress);
|
||||
|
||||
|
||||
// Check if user_registered step is completed
|
||||
const userRegisteredStep = userProgress.steps.find(s => s.step_name === 'user_registered');
|
||||
if (!userRegisteredStep?.completed) {
|
||||
console.log('⏳ Waiting for user_registered step to be auto-completed...');
|
||||
return; // Wait for auto-completion to finish
|
||||
}
|
||||
|
||||
// Find the current step index based on backend progress
|
||||
const currentStepFromBackend = userProgress.current_step;
|
||||
let stepIndex = STEPS.findIndex(step => step.id === currentStepFromBackend);
|
||||
|
||||
console.log(`🎯 Backend current step: "${currentStepFromBackend}", found at index: ${stepIndex}`);
|
||||
|
||||
// If current step is not found (e.g., suppliers step), find the next incomplete step
|
||||
if (stepIndex === -1) {
|
||||
console.log('🔍 Current step not found in UI steps, finding first incomplete step...');
|
||||
|
||||
// Find the first incomplete step that user can access
|
||||
for (let i = 0; i < STEPS.length; i++) {
|
||||
const step = STEPS[i];
|
||||
const stepProgress = userProgress.steps.find(s => s.step_name === step.id);
|
||||
|
||||
if (!stepProgress?.completed) {
|
||||
stepIndex = i;
|
||||
console.log(`📍 Found first incomplete step: "${step.id}" at index ${i}`);
|
||||
break;
|
||||
|
||||
let stepIndex = 0; // Default to first step
|
||||
|
||||
// If this is a new tenant creation, always start from the beginning
|
||||
if (isNewTenant) {
|
||||
console.log('🆕 New tenant creation - starting from first step');
|
||||
stepIndex = 0;
|
||||
} else {
|
||||
// Find the current step index based on backend progress
|
||||
const currentStepFromBackend = userProgress.current_step;
|
||||
stepIndex = STEPS.findIndex(step => step.id === currentStepFromBackend);
|
||||
|
||||
console.log(`🎯 Backend current step: "${currentStepFromBackend}", found at index: ${stepIndex}`);
|
||||
|
||||
// If current step is not found (e.g., suppliers step), find the next incomplete step
|
||||
if (stepIndex === -1) {
|
||||
console.log('🔍 Current step not found in UI steps, finding first incomplete step...');
|
||||
|
||||
// Find the first incomplete step that user can access
|
||||
for (let i = 0; i < STEPS.length; i++) {
|
||||
const step = STEPS[i];
|
||||
const stepProgress = userProgress.steps.find(s => s.step_name === step.id);
|
||||
|
||||
if (!stepProgress?.completed) {
|
||||
stepIndex = i;
|
||||
console.log(`📍 Found first incomplete step: "${step.id}" at index ${i}`);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// If all visible steps are completed, go to last step
|
||||
if (stepIndex === -1) {
|
||||
stepIndex = STEPS.length - 1;
|
||||
console.log('✅ All steps completed, going to last step');
|
||||
}
|
||||
}
|
||||
|
||||
// If all visible steps are completed, go to last step
|
||||
if (stepIndex === -1) {
|
||||
stepIndex = STEPS.length - 1;
|
||||
console.log('✅ All steps completed, going to last step');
|
||||
|
||||
// Ensure user can't skip ahead - find the first incomplete step
|
||||
const firstIncompleteStepIndex = STEPS.findIndex(step => {
|
||||
const stepProgress = userProgress.steps.find(s => s.step_name === step.id);
|
||||
return !stepProgress?.completed;
|
||||
});
|
||||
|
||||
if (firstIncompleteStepIndex !== -1 && stepIndex > firstIncompleteStepIndex) {
|
||||
console.log(`🚫 User trying to skip ahead. Redirecting to first incomplete step at index ${firstIncompleteStepIndex}`);
|
||||
stepIndex = firstIncompleteStepIndex;
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure user can't skip ahead - find the first incomplete step
|
||||
const firstIncompleteStepIndex = STEPS.findIndex(step => {
|
||||
const stepProgress = userProgress.steps.find(s => s.step_name === step.id);
|
||||
return !stepProgress?.completed;
|
||||
});
|
||||
|
||||
if (firstIncompleteStepIndex !== -1 && stepIndex > firstIncompleteStepIndex) {
|
||||
console.log(`🚫 User trying to skip ahead. Redirecting to first incomplete step at index ${firstIncompleteStepIndex}`);
|
||||
stepIndex = firstIncompleteStepIndex;
|
||||
}
|
||||
|
||||
|
||||
console.log(`🎯 Final step index: ${stepIndex} ("${STEPS[stepIndex]?.id}")`);
|
||||
|
||||
|
||||
if (stepIndex !== currentStepIndex) {
|
||||
setCurrentStepIndex(stepIndex);
|
||||
}
|
||||
setIsInitialized(true);
|
||||
}
|
||||
}, [userProgress, isInitialized, currentStepIndex]);
|
||||
}, [userProgress, isInitialized, currentStepIndex, isNewTenant]);
|
||||
|
||||
const currentStep = STEPS[currentStepIndex];
|
||||
|
||||
@@ -214,7 +243,13 @@ export const OnboardingWizard: React.FC = () => {
|
||||
}
|
||||
|
||||
if (currentStep.id === 'completion') {
|
||||
navigate('/app');
|
||||
// Navigate to dashboard after completion
|
||||
if (isNewTenant) {
|
||||
// For new tenant creation, navigate to dashboard and remove the new param
|
||||
navigate('/app/dashboard');
|
||||
} else {
|
||||
navigate('/app');
|
||||
}
|
||||
} else {
|
||||
// Auto-advance to next step after successful completion
|
||||
if (currentStepIndex < STEPS.length - 1) {
|
||||
@@ -246,8 +281,8 @@ export const OnboardingWizard: React.FC = () => {
|
||||
}
|
||||
};
|
||||
|
||||
// Show loading state while initializing progress
|
||||
if (isLoadingProgress || !isInitialized) {
|
||||
// Show loading state while initializing progress (skip for new tenant)
|
||||
if (!isNewTenant && (isLoadingProgress || !isInitialized)) {
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto px-4 sm:px-6">
|
||||
<Card padding="lg" shadow="lg">
|
||||
@@ -262,8 +297,8 @@ export const OnboardingWizard: React.FC = () => {
|
||||
);
|
||||
}
|
||||
|
||||
// Show error state if progress fails to load
|
||||
if (progressError) {
|
||||
// Show error state if progress fails to load (skip for new tenant)
|
||||
if (!isNewTenant && progressError) {
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto px-4 sm:px-6">
|
||||
<Card padding="lg" shadow="lg">
|
||||
@@ -297,19 +332,47 @@ export const OnboardingWizard: React.FC = () => {
|
||||
}
|
||||
|
||||
const StepComponent = currentStep.component;
|
||||
const progressPercentage = userProgress?.completion_percentage || ((currentStepIndex + 1) / STEPS.length) * 100;
|
||||
|
||||
// Calculate progress percentage - reset for new tenant creation
|
||||
const progressPercentage = isNewTenant
|
||||
? ((currentStepIndex + 1) / STEPS.length) * 100 // For new tenant, base progress only on current step
|
||||
: userProgress?.completion_percentage || ((currentStepIndex + 1) / STEPS.length) * 100;
|
||||
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto px-4 sm:px-6 space-y-4 sm:space-y-6 pb-6">
|
||||
{/* New Tenant Info Banner */}
|
||||
{isNewTenant && (
|
||||
<Card className="bg-gradient-to-r from-[var(--color-primary)]/5 to-[var(--color-primary)]/10 border-[var(--color-primary)]/20">
|
||||
<CardBody className="py-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-8 h-8 bg-[var(--color-primary)]/10 rounded-full flex items-center justify-center">
|
||||
<Building2 className="w-4 h-4 text-[var(--color-primary)]" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-[var(--text-primary)]">
|
||||
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.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardBody>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Enhanced Progress Header */}
|
||||
<Card shadow="sm" padding="lg">
|
||||
<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)]">
|
||||
Bienvenido a Bakery IA
|
||||
{isNewTenant ? 'Crear Nueva Organización' : 'Bienvenido a Bakery IA'}
|
||||
</h1>
|
||||
<p className="text-[var(--text-secondary)] text-xs sm:text-sm mt-1">
|
||||
Configura tu sistema de gestión inteligente paso a paso
|
||||
{isNewTenant
|
||||
? 'Configura tu nueva panadería desde cero'
|
||||
: 'Configura tu sistema de gestión inteligente paso a paso'
|
||||
}
|
||||
</p>
|
||||
</div>
|
||||
<div className="text-center sm:text-right">
|
||||
@@ -318,6 +381,7 @@ export const OnboardingWizard: React.FC = () => {
|
||||
</div>
|
||||
<div className="text-xs text-[var(--text-tertiary)]">
|
||||
{Math.round(progressPercentage)}% completado
|
||||
{isNewTenant && <span className="text-[var(--color-primary)] ml-1">(nuevo)</span>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -334,7 +398,10 @@ export const OnboardingWizard: React.FC = () => {
|
||||
<div className="sm:hidden">
|
||||
<div className="flex space-x-4 overflow-x-auto pb-2 px-1">
|
||||
{STEPS.map((step, index) => {
|
||||
const isCompleted = userProgress?.steps.find(s => s.step_name === step.id)?.completed || index < currentStepIndex;
|
||||
// For new tenant creation, only show completed if index is less than current step
|
||||
const isCompleted = isNewTenant
|
||||
? index < currentStepIndex
|
||||
: userProgress?.steps.find(s => s.step_name === step.id)?.completed || index < currentStepIndex;
|
||||
const isCurrent = index === currentStepIndex;
|
||||
|
||||
return (
|
||||
@@ -377,7 +444,10 @@ export const OnboardingWizard: React.FC = () => {
|
||||
{/* Desktop Step Indicators */}
|
||||
<div className="hidden sm:flex sm:justify-between">
|
||||
{STEPS.map((step, index) => {
|
||||
const isCompleted = userProgress?.steps.find(s => s.step_name === step.id)?.completed || index < currentStepIndex;
|
||||
// For new tenant creation, only show completed if index is less than current step
|
||||
const isCompleted = isNewTenant
|
||||
? index < currentStepIndex
|
||||
: userProgress?.steps.find(s => s.step_name === step.id)?.completed || index < currentStepIndex;
|
||||
const isCurrent = index === currentStepIndex;
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,23 +1,20 @@
|
||||
import React, { useState, useCallback, forwardRef } from 'react';
|
||||
import { clsx } from 'clsx';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useAuthUser, useIsAuthenticated, useAuthActions } from '../../../stores';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useAuthUser, useIsAuthenticated } from '../../../stores';
|
||||
import { useTheme } from '../../../contexts/ThemeContext';
|
||||
import { useNotifications } from '../../../hooks/useNotifications';
|
||||
import { Button } from '../../ui';
|
||||
import { Avatar } from '../../ui';
|
||||
import { Badge } from '../../ui';
|
||||
import { Modal } from '../../ui';
|
||||
import { TenantSwitcher } from '../../ui/TenantSwitcher';
|
||||
import { ThemeToggle } from '../../ui/ThemeToggle';
|
||||
import { NotificationPanel } from '../../ui/NotificationPanel/NotificationPanel';
|
||||
import { CompactLanguageSelector } from '../../ui/LanguageSelector';
|
||||
import {
|
||||
Menu,
|
||||
Search,
|
||||
Bell,
|
||||
Settings,
|
||||
User,
|
||||
LogOut,
|
||||
X
|
||||
} from 'lucide-react';
|
||||
|
||||
@@ -43,10 +40,6 @@ export interface HeaderProps {
|
||||
* Show/hide theme toggle
|
||||
*/
|
||||
showThemeToggle?: boolean;
|
||||
/**
|
||||
* Show/hide user menu
|
||||
*/
|
||||
showUserMenu?: boolean;
|
||||
/**
|
||||
* Custom logo component
|
||||
*/
|
||||
@@ -67,17 +60,14 @@ export interface HeaderProps {
|
||||
|
||||
export interface HeaderRef {
|
||||
focusSearch: () => void;
|
||||
toggleUserMenu: () => void;
|
||||
closeUserMenu: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Header - Top navigation header with logo, user menu, notifications, theme toggle
|
||||
*
|
||||
* Header - Top navigation header with logo, notifications, theme toggle
|
||||
*
|
||||
* Features:
|
||||
* - Logo/brand area with responsive sizing
|
||||
* - Global search functionality with keyboard shortcuts
|
||||
* - User avatar with dropdown menu
|
||||
* - Notifications bell with badge count
|
||||
* - Theme toggle button (light/dark/system)
|
||||
* - Mobile hamburger menu integration
|
||||
@@ -90,16 +80,15 @@ export const Header = forwardRef<HeaderRef, HeaderProps>(({
|
||||
showSearch = true,
|
||||
showNotifications = true,
|
||||
showThemeToggle = true,
|
||||
showUserMenu = true,
|
||||
logo,
|
||||
searchPlaceholder = 'Buscar...',
|
||||
searchPlaceholder,
|
||||
notificationCount = 0,
|
||||
onNotificationClick,
|
||||
}, ref) => {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const user = useAuthUser();
|
||||
const isAuthenticated = useIsAuthenticated();
|
||||
const { logout } = useAuthActions();
|
||||
const { theme, resolvedTheme, setTheme } = useTheme();
|
||||
const {
|
||||
notifications,
|
||||
@@ -111,34 +100,22 @@ export const Header = forwardRef<HeaderRef, HeaderProps>(({
|
||||
clearAll
|
||||
} = useNotifications();
|
||||
|
||||
const [isUserMenuOpen, setIsUserMenuOpen] = useState(false);
|
||||
const [isSearchFocused, setIsSearchFocused] = useState(false);
|
||||
const [searchValue, setSearchValue] = useState('');
|
||||
const [isNotificationPanelOpen, setIsNotificationPanelOpen] = useState(false);
|
||||
|
||||
const searchInputRef = React.useRef<HTMLInputElement>(null);
|
||||
const defaultSearchPlaceholder = searchPlaceholder || t('common:forms.search_placeholder', 'Search...');
|
||||
|
||||
// Focus search input
|
||||
const focusSearch = useCallback(() => {
|
||||
searchInputRef.current?.focus();
|
||||
}, []);
|
||||
|
||||
// Toggle user menu
|
||||
const toggleUserMenu = useCallback(() => {
|
||||
setIsUserMenuOpen(prev => !prev);
|
||||
}, []);
|
||||
|
||||
// Close user menu
|
||||
const closeUserMenu = useCallback(() => {
|
||||
setIsUserMenuOpen(false);
|
||||
}, []);
|
||||
|
||||
// Expose ref methods
|
||||
React.useImperativeHandle(ref, () => ({
|
||||
focusSearch,
|
||||
toggleUserMenu,
|
||||
closeUserMenu,
|
||||
}), [focusSearch, toggleUserMenu, closeUserMenu]);
|
||||
}), [focusSearch]);
|
||||
|
||||
// Handle search
|
||||
const handleSearchChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
@@ -158,11 +135,6 @@ export const Header = forwardRef<HeaderRef, HeaderProps>(({
|
||||
searchInputRef.current?.focus();
|
||||
}, []);
|
||||
|
||||
// Handle logout
|
||||
const handleLogout = useCallback(async () => {
|
||||
await logout();
|
||||
setIsUserMenuOpen(false);
|
||||
}, [logout]);
|
||||
|
||||
// Keyboard shortcuts
|
||||
React.useEffect(() => {
|
||||
@@ -175,7 +147,6 @@ export const Header = forwardRef<HeaderRef, HeaderProps>(({
|
||||
|
||||
// Escape to close menus
|
||||
if (e.key === 'Escape') {
|
||||
setIsUserMenuOpen(false);
|
||||
setIsNotificationPanelOpen(false);
|
||||
if (isSearchFocused) {
|
||||
searchInputRef.current?.blur();
|
||||
@@ -191,9 +162,6 @@ export const Header = forwardRef<HeaderRef, HeaderProps>(({
|
||||
React.useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
const target = event.target as Element;
|
||||
if (!target.closest('[data-user-menu]')) {
|
||||
setIsUserMenuOpen(false);
|
||||
}
|
||||
if (!target.closest('[data-notification-panel]')) {
|
||||
setIsNotificationPanelOpen(false);
|
||||
}
|
||||
@@ -287,7 +255,7 @@ export const Header = forwardRef<HeaderRef, HeaderProps>(({
|
||||
onChange={handleSearchChange}
|
||||
onFocus={() => setIsSearchFocused(true)}
|
||||
onBlur={() => setIsSearchFocused(false)}
|
||||
placeholder={searchPlaceholder}
|
||||
placeholder={defaultSearchPlaceholder}
|
||||
className={clsx(
|
||||
'w-full pl-10 pr-12 py-2.5 text-sm',
|
||||
'bg-[var(--bg-secondary)] border border-[var(--border-primary)]',
|
||||
@@ -297,14 +265,14 @@ export const Header = forwardRef<HeaderRef, HeaderProps>(({
|
||||
'placeholder:text-[var(--text-tertiary)]',
|
||||
'h-9'
|
||||
)}
|
||||
aria-label="Buscar en la aplicación"
|
||||
aria-label={t('common:accessibility.search', 'Search in the application')}
|
||||
/>
|
||||
{searchValue ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={clearSearch}
|
||||
className="absolute right-3 top-0 bottom-0 flex items-center p-1 hover:bg-[var(--bg-tertiary)] rounded-full transition-colors"
|
||||
aria-label="Limpiar búsqueda"
|
||||
aria-label={t('common:actions.clear', 'Clear search')}
|
||||
>
|
||||
<X className="h-3 w-3 text-[var(--text-tertiary)]" />
|
||||
</button>
|
||||
@@ -330,12 +298,15 @@ export const Header = forwardRef<HeaderRef, HeaderProps>(({
|
||||
size="sm"
|
||||
onClick={focusSearch}
|
||||
className="md:hidden w-10 h-10 p-0 flex items-center justify-center"
|
||||
aria-label="Buscar"
|
||||
aria-label={t('common:actions.search', 'Search')}
|
||||
>
|
||||
<Search className="h-5 w-5" />
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* Language selector */}
|
||||
<CompactLanguageSelector className="w-auto min-w-[60px]" />
|
||||
|
||||
{/* Theme toggle */}
|
||||
{showThemeToggle && (
|
||||
<ThemeToggle variant="button" size="md" />
|
||||
@@ -353,8 +324,8 @@ export const Header = forwardRef<HeaderRef, HeaderProps>(({
|
||||
!isConnected && "opacity-50",
|
||||
isNotificationPanelOpen && "bg-[var(--bg-secondary)]"
|
||||
)}
|
||||
aria-label={`Notificaciones${unreadCount > 0 ? ` (${unreadCount})` : ''}${!isConnected ? ' - Desconectado' : ''}`}
|
||||
title={!isConnected ? 'Sin conexión en tiempo real' : undefined}
|
||||
aria-label={`${t('common:navigation.notifications', 'Notifications')}${unreadCount > 0 ? ` (${unreadCount})` : ''}${!isConnected ? ` - ${t('common:status.disconnected', 'Disconnected')}` : ''}`}
|
||||
title={!isConnected ? t('common:status.no_realtime_connection', 'No real-time connection') : undefined}
|
||||
aria-expanded={isNotificationPanelOpen}
|
||||
aria-haspopup="true"
|
||||
>
|
||||
@@ -385,93 +356,6 @@ export const Header = forwardRef<HeaderRef, HeaderProps>(({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* User menu */}
|
||||
{showUserMenu && user && (
|
||||
<div className="relative" data-user-menu>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={toggleUserMenu}
|
||||
className={clsx(
|
||||
"flex items-center gap-2 pl-2 pr-3 py-1.5 h-9 min-w-0 rounded-lg",
|
||||
"hover:bg-[var(--bg-secondary)] hover:ring-2 hover:ring-[var(--color-primary)]/20",
|
||||
"transition-all duration-200 ease-in-out",
|
||||
"active:scale-95",
|
||||
isUserMenuOpen && "bg-[var(--bg-secondary)] ring-2 ring-[var(--color-primary)]/20"
|
||||
)}
|
||||
aria-label="Menú de usuario"
|
||||
aria-expanded={isUserMenuOpen}
|
||||
aria-haspopup="true"
|
||||
>
|
||||
<Avatar
|
||||
src={user.avatar}
|
||||
alt={user.name}
|
||||
name={user.name}
|
||||
size="xs"
|
||||
className={clsx(
|
||||
"flex-shrink-0 transition-all duration-200",
|
||||
"hover:ring-2 hover:ring-[var(--color-primary)]/30",
|
||||
isUserMenuOpen && "ring-2 ring-[var(--color-primary)]/30"
|
||||
)}
|
||||
/>
|
||||
</Button>
|
||||
|
||||
{isUserMenuOpen && (
|
||||
<div className="absolute right-0 top-full mt-2 w-56 bg-[var(--bg-primary)] border border-[var(--border-primary)] rounded-lg shadow-lg py-2 z-[var(--z-dropdown)]">
|
||||
{/* User info */}
|
||||
<div className="px-4 py-3 border-b border-[var(--border-primary)] flex items-center gap-3">
|
||||
<Avatar
|
||||
src={user.avatar || undefined}
|
||||
alt={user.name}
|
||||
name={user.name}
|
||||
size="sm"
|
||||
className="flex-shrink-0"
|
||||
/>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-xs text-[var(--text-tertiary)] truncate">
|
||||
{user.email}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Menu items */}
|
||||
<div className="py-1">
|
||||
<button
|
||||
onClick={() => {
|
||||
navigate('/app/settings/profile');
|
||||
setIsUserMenuOpen(false);
|
||||
}}
|
||||
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
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
navigate('/app/settings');
|
||||
setIsUserMenuOpen(false);
|
||||
}}
|
||||
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
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Logout */}
|
||||
<div className="border-t border-[var(--border-primary)] pt-1">
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
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
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</header>
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
import React, { useState, useCallback, forwardRef, useMemo } from 'react';
|
||||
import { clsx } from 'clsx';
|
||||
import { useLocation, useNavigate } from 'react-router-dom';
|
||||
import { useAuthUser, useIsAuthenticated } from '../../../stores';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useAuthUser, useIsAuthenticated, useAuthActions } from '../../../stores';
|
||||
import { useCurrentTenantAccess } from '../../../stores/tenant.store';
|
||||
import { getNavigationRoutes, canAccessRoute, ROUTES } from '../../../router/routes.config';
|
||||
import { useSubscriptionAwareRoutes } from '../../../hooks/useSubscriptionAwareRoutes';
|
||||
import { Button } from '../../ui';
|
||||
import { Badge } from '../../ui';
|
||||
import { Tooltip } from '../../ui';
|
||||
import { Avatar } from '../../ui';
|
||||
import {
|
||||
LayoutDashboard,
|
||||
Package,
|
||||
@@ -28,7 +30,10 @@ import {
|
||||
ChevronRight,
|
||||
ChevronDown,
|
||||
Dot,
|
||||
Menu
|
||||
Menu,
|
||||
LogOut,
|
||||
MoreHorizontal,
|
||||
X
|
||||
} from 'lucide-react';
|
||||
|
||||
export interface SidebarProps {
|
||||
@@ -127,36 +132,71 @@ export const Sidebar = forwardRef<SidebarRef, SidebarProps>(({
|
||||
showCollapseButton = true,
|
||||
showFooter = true,
|
||||
}, ref) => {
|
||||
const { t } = useTranslation();
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const user = useAuthUser();
|
||||
const isAuthenticated = useIsAuthenticated();
|
||||
const currentTenantAccess = useCurrentTenantAccess();
|
||||
const { logout } = useAuthActions();
|
||||
|
||||
const [expandedItems, setExpandedItems] = useState<Set<string>>(new Set());
|
||||
const [hoveredItem, setHoveredItem] = useState<string | null>(null);
|
||||
const [isProfileMenuOpen, setIsProfileMenuOpen] = useState(false);
|
||||
const sidebarRef = React.useRef<HTMLDivElement>(null);
|
||||
|
||||
// Get subscription-aware navigation routes
|
||||
const baseNavigationRoutes = useMemo(() => getNavigationRoutes(), []);
|
||||
const { filteredRoutes: subscriptionFilteredRoutes } = useSubscriptionAwareRoutes(baseNavigationRoutes);
|
||||
|
||||
// Map route paths to translation keys
|
||||
const getTranslationKey = (routePath: string): string => {
|
||||
const pathMappings: Record<string, string> = {
|
||||
'/app/dashboard': 'navigation.dashboard',
|
||||
'/app/operations': 'navigation.operations',
|
||||
'/app/operations/procurement': 'navigation.procurement',
|
||||
'/app/operations/production': 'navigation.production',
|
||||
'/app/operations/pos': 'navigation.pos',
|
||||
'/app/bakery': 'navigation.bakery',
|
||||
'/app/bakery/recipes': 'navigation.recipes',
|
||||
'/app/database': 'navigation.data',
|
||||
'/app/database/inventory': 'navigation.inventory',
|
||||
'/app/analytics': 'navigation.analytics',
|
||||
'/app/analytics/forecasting': 'navigation.forecasting',
|
||||
'/app/analytics/sales': 'navigation.sales',
|
||||
'/app/analytics/performance': 'navigation.performance',
|
||||
'/app/ai': 'navigation.insights',
|
||||
'/app/communications': 'navigation.communications',
|
||||
'/app/communications/notifications': 'navigation.notifications',
|
||||
'/app/communications/alerts': 'navigation.alerts',
|
||||
};
|
||||
|
||||
return pathMappings[routePath] || routePath;
|
||||
};
|
||||
|
||||
// Convert routes to navigation items - memoized
|
||||
const navigationItems = useMemo(() => {
|
||||
const convertRoutesToItems = (routes: typeof subscriptionFilteredRoutes): NavigationItem[] => {
|
||||
return routes.map(route => ({
|
||||
id: route.path,
|
||||
label: route.title,
|
||||
path: route.path,
|
||||
icon: route.icon ? iconMap[route.icon] : undefined,
|
||||
requiredPermissions: route.requiredPermissions,
|
||||
requiredRoles: route.requiredRoles,
|
||||
children: route.children ? convertRoutesToItems(route.children) : undefined,
|
||||
}));
|
||||
return routes.map(route => {
|
||||
const translationKey = getTranslationKey(route.path);
|
||||
const label = translationKey.startsWith('navigation.')
|
||||
? t(`common:${translationKey}`, route.title)
|
||||
: route.title;
|
||||
|
||||
return {
|
||||
id: route.path,
|
||||
label,
|
||||
path: route.path,
|
||||
icon: route.icon ? iconMap[route.icon] : undefined,
|
||||
requiredPermissions: route.requiredPermissions,
|
||||
requiredRoles: route.requiredRoles,
|
||||
children: route.children ? convertRoutesToItems(route.children) : undefined,
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
return customItems || convertRoutesToItems(subscriptionFilteredRoutes);
|
||||
}, [customItems, subscriptionFilteredRoutes]);
|
||||
}, [customItems, subscriptionFilteredRoutes, t]);
|
||||
|
||||
// Filter items based on user permissions - memoized to prevent infinite re-renders
|
||||
const visibleItems = useMemo(() => {
|
||||
@@ -219,6 +259,17 @@ export const Sidebar = forwardRef<SidebarRef, SidebarProps>(({
|
||||
}
|
||||
}, [navigate, onClose]);
|
||||
|
||||
// Handle logout
|
||||
const handleLogout = useCallback(async () => {
|
||||
await logout();
|
||||
setIsProfileMenuOpen(false);
|
||||
}, [logout]);
|
||||
|
||||
// Handle profile menu toggle
|
||||
const handleProfileMenuToggle = useCallback(() => {
|
||||
setIsProfileMenuOpen(prev => !prev);
|
||||
}, []);
|
||||
|
||||
// Scroll to item
|
||||
const scrollToItem = useCallback((path: string) => {
|
||||
const element = sidebarRef.current?.querySelector(`[data-path="${path}"]`);
|
||||
@@ -277,8 +328,12 @@ export const Sidebar = forwardRef<SidebarRef, SidebarProps>(({
|
||||
// Handle keyboard navigation and touch gestures
|
||||
React.useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape' && isOpen && onClose) {
|
||||
onClose();
|
||||
if (e.key === 'Escape') {
|
||||
if (isProfileMenuOpen) {
|
||||
setIsProfileMenuOpen(false);
|
||||
} else if (isOpen && onClose) {
|
||||
onClose();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -310,13 +365,28 @@ export const Sidebar = forwardRef<SidebarRef, SidebarProps>(({
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
document.addEventListener('touchstart', handleTouchStart, { passive: true });
|
||||
document.addEventListener('touchmove', handleTouchMove, { passive: true });
|
||||
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('keydown', handleKeyDown);
|
||||
document.removeEventListener('touchstart', handleTouchStart);
|
||||
document.removeEventListener('touchmove', handleTouchMove);
|
||||
};
|
||||
}, [isOpen, onClose]);
|
||||
}, [isOpen, onClose, isProfileMenuOpen]);
|
||||
|
||||
// Handle click outside profile menu
|
||||
React.useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
const target = event.target as Element;
|
||||
if (!target.closest('[data-profile-menu]')) {
|
||||
setIsProfileMenuOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (isProfileMenuOpen) {
|
||||
document.addEventListener('click', handleClickOutside);
|
||||
return () => document.removeEventListener('click', handleClickOutside);
|
||||
}
|
||||
}, [isProfileMenuOpen]);
|
||||
|
||||
// Render submenu overlay for collapsed sidebar
|
||||
const renderSubmenuOverlay = (item: NavigationItem) => {
|
||||
@@ -383,14 +453,14 @@ export const Sidebar = forwardRef<SidebarRef, SidebarProps>(({
|
||||
>
|
||||
<div className="relative">
|
||||
{ItemIcon && (
|
||||
<ItemIcon
|
||||
<ItemIcon
|
||||
className={clsx(
|
||||
'flex-shrink-0 transition-colors duration-200',
|
||||
isCollapsed ? 'w-5 h-5' : 'w-4 h-4 mr-3',
|
||||
isActive
|
||||
? 'text-[var(--color-primary)]'
|
||||
isCollapsed && level === 0 ? 'w-5 h-5' : 'w-4 h-4 mr-3',
|
||||
isActive
|
||||
? 'text-[var(--color-primary)]'
|
||||
: 'text-[var(--text-tertiary)] group-hover:text-[var(--text-primary)]'
|
||||
)}
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -466,7 +536,7 @@ export const Sidebar = forwardRef<SidebarRef, SidebarProps>(({
|
||||
isActive && 'bg-[var(--color-primary)]/10 border-l-2 border-[var(--color-primary)]',
|
||||
!isActive && 'hover:bg-[var(--bg-secondary)]',
|
||||
item.disabled && 'opacity-50 cursor-not-allowed',
|
||||
isCollapsed && !hasChildren ? 'flex justify-center items-center p-2 mx-1' : 'p-3'
|
||||
isCollapsed && level === 0 ? 'flex justify-center items-center p-3 aspect-square' : 'p-3'
|
||||
)}
|
||||
aria-expanded={hasChildren ? isExpanded : undefined}
|
||||
aria-current={isActive ? 'page' : undefined}
|
||||
@@ -524,15 +594,99 @@ export const Sidebar = forwardRef<SidebarRef, SidebarProps>(({
|
||||
isCollapsed ? 'w-[var(--sidebar-collapsed-width)]' : 'w-[var(--sidebar-width)]',
|
||||
className
|
||||
)}
|
||||
aria-label="Navegación principal"
|
||||
aria-label={t('common:accessibility.menu', 'Main navigation')}
|
||||
>
|
||||
{/* Navigation */}
|
||||
<nav className={clsx('flex-1 overflow-y-auto', isCollapsed ? 'px-1 py-4' : 'p-4')}>
|
||||
<ul className={clsx(isCollapsed ? 'space-y-1' : 'space-y-2')}>
|
||||
<nav className={clsx('flex-1 overflow-y-auto', isCollapsed ? 'px-2 py-4' : 'p-4')}>
|
||||
<ul className={clsx(isCollapsed ? 'space-y-2' : 'space-y-2')}>
|
||||
{visibleItems.map(item => renderItem(item))}
|
||||
</ul>
|
||||
</nav>
|
||||
|
||||
{/* Profile section */}
|
||||
{user && (
|
||||
<div className="border-t border-[var(--border-primary)]">
|
||||
<div className="relative" data-profile-menu>
|
||||
<button
|
||||
onClick={handleProfileMenuToggle}
|
||||
className={clsx(
|
||||
'w-full flex items-center transition-all duration-200',
|
||||
'hover:bg-[var(--bg-secondary)] focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)]/20',
|
||||
isCollapsed ? 'justify-center p-3' : 'p-4 gap-3',
|
||||
isProfileMenuOpen && 'bg-[var(--bg-secondary)]'
|
||||
)}
|
||||
aria-label="Menú de perfil"
|
||||
aria-expanded={isProfileMenuOpen}
|
||||
aria-haspopup="true"
|
||||
>
|
||||
<Avatar
|
||||
src={user.avatar}
|
||||
alt={user.name}
|
||||
name={user.name}
|
||||
size={isCollapsed ? "sm" : "md"}
|
||||
className="flex-shrink-0"
|
||||
/>
|
||||
{!isCollapsed && (
|
||||
<>
|
||||
<div className="flex-1 min-w-0 text-left">
|
||||
<div className="text-sm font-medium text-[var(--text-primary)] truncate">
|
||||
{user.name}
|
||||
</div>
|
||||
<div className="text-xs text-[var(--text-tertiary)] truncate">
|
||||
{user.email}
|
||||
</div>
|
||||
</div>
|
||||
<MoreHorizontal className="w-4 h-4 text-[var(--text-tertiary)] flex-shrink-0" />
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Profile menu dropdown */}
|
||||
{isProfileMenuOpen && (
|
||||
<div className={clsx(
|
||||
'absolute bg-[var(--bg-primary)] border border-[var(--border-primary)] rounded-lg shadow-lg py-2 z-[var(--z-dropdown)]',
|
||||
isCollapsed ? 'left-full bottom-0 ml-2 min-w-[200px]' : 'left-4 right-4 bottom-full mb-2'
|
||||
)}>
|
||||
<div className="py-1">
|
||||
<button
|
||||
onClick={() => {
|
||||
navigate('/app/settings/profile');
|
||||
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
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
navigate('/app/settings');
|
||||
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
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="border-t border-[var(--border-primary)] pt-1">
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
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
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Collapse button */}
|
||||
{showCollapseButton && (
|
||||
<div className={clsx('border-t border-[var(--border-primary)]', isCollapsed ? 'p-2' : 'p-4')}>
|
||||
@@ -541,60 +695,57 @@ export const Sidebar = forwardRef<SidebarRef, SidebarProps>(({
|
||||
size="sm"
|
||||
onClick={onToggleCollapse}
|
||||
className={clsx(
|
||||
'w-full flex items-center justify-center',
|
||||
isCollapsed ? 'p-2' : 'px-4 py-2'
|
||||
'w-full flex items-center transition-colors duration-200',
|
||||
isCollapsed ? 'justify-center p-3 aspect-square' : 'justify-start px-4 py-2'
|
||||
)}
|
||||
aria-label={isCollapsed ? 'Expandir sidebar' : 'Contraer sidebar'}
|
||||
aria-label={isCollapsed ? t('common:actions.expand', 'Expand sidebar') : t('common:actions.collapse', 'Collapse sidebar')}
|
||||
>
|
||||
{isCollapsed ? (
|
||||
<ChevronRight className="w-4 h-4" />
|
||||
) : (
|
||||
<>
|
||||
<ChevronLeft className="w-4 h-4 mr-2" />
|
||||
<span className="text-sm">Contraer</span>
|
||||
<span className="text-sm">{t('common:actions.collapse', 'Collapse')}</span>
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Footer */}
|
||||
{showFooter && !isCollapsed && (
|
||||
<div className="p-4 border-t border-[var(--border-primary)]">
|
||||
<div className="text-xs text-[var(--text-tertiary)] text-center">
|
||||
Panadería IA v2.0.0
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</aside>
|
||||
|
||||
{/* Mobile Drawer */}
|
||||
<aside
|
||||
className={clsx(
|
||||
'fixed inset-y-0 left-0 w-[var(--sidebar-width)] max-w-[85vw]',
|
||||
'fixed inset-y-0 left-0 w-[var(--sidebar-width)] max-w-[90vw]',
|
||||
'bg-[var(--bg-primary)] border-r border-[var(--border-primary)]',
|
||||
'transition-transform duration-300 ease-in-out z-[var(--z-modal)]',
|
||||
'lg:hidden flex flex-col',
|
||||
'shadow-xl',
|
||||
'shadow-2xl backdrop-blur-sm',
|
||||
isOpen ? 'translate-x-0' : '-translate-x-full'
|
||||
)}
|
||||
aria-label="Navegación principal"
|
||||
aria-label={t('common:accessibility.menu', 'Main navigation')}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
>
|
||||
{/* Mobile header */}
|
||||
<div className="flex items-center justify-between p-4 border-b border-[var(--border-primary)]">
|
||||
<h2 className="text-lg font-semibold text-[var(--text-primary)]">
|
||||
Navegación
|
||||
</h2>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-8 h-8 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-sm flex-shrink-0">
|
||||
PI
|
||||
</div>
|
||||
<h2 className="text-lg font-semibold text-[var(--text-primary)]">
|
||||
Panadería IA
|
||||
</h2>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={onClose}
|
||||
className="p-2"
|
||||
className="p-2 hover:bg-[var(--bg-secondary)]"
|
||||
aria-label="Cerrar navegación"
|
||||
>
|
||||
<Menu className="w-4 h-4" />
|
||||
<X className="w-5 h-5" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -605,14 +756,82 @@ export const Sidebar = forwardRef<SidebarRef, SidebarProps>(({
|
||||
</ul>
|
||||
</nav>
|
||||
|
||||
{/* Footer */}
|
||||
{showFooter && (
|
||||
<div className="p-4 border-t border-[var(--border-primary)]">
|
||||
<div className="text-xs text-[var(--text-tertiary)] text-center">
|
||||
Panadería IA v2.0.0
|
||||
{/* Profile section - Mobile */}
|
||||
{user && (
|
||||
<div className="border-t border-[var(--border-primary)]">
|
||||
<div className="relative" data-profile-menu>
|
||||
<button
|
||||
onClick={handleProfileMenuToggle}
|
||||
className={clsx(
|
||||
'w-full flex items-center p-4 gap-3 transition-all duration-200',
|
||||
'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-expanded={isProfileMenuOpen}
|
||||
aria-haspopup="true"
|
||||
>
|
||||
<Avatar
|
||||
src={user.avatar}
|
||||
alt={user.name}
|
||||
name={user.name}
|
||||
size="md"
|
||||
className="flex-shrink-0"
|
||||
/>
|
||||
<div className="flex-1 min-w-0 text-left">
|
||||
<div className="text-sm font-medium text-[var(--text-primary)] truncate">
|
||||
{user.name}
|
||||
</div>
|
||||
<div className="text-xs text-[var(--text-tertiary)] truncate">
|
||||
{user.email}
|
||||
</div>
|
||||
</div>
|
||||
<MoreHorizontal className="w-4 h-4 text-[var(--text-tertiary)] flex-shrink-0" />
|
||||
</button>
|
||||
|
||||
{/* Profile menu dropdown - Mobile */}
|
||||
{isProfileMenuOpen && (
|
||||
<div className="mx-4 mb-2 bg-[var(--bg-secondary)] border border-[var(--border-primary)] rounded-lg py-2">
|
||||
<div className="py-1">
|
||||
<button
|
||||
onClick={() => {
|
||||
navigate('/app/settings/profile');
|
||||
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-tertiary)] transition-colors"
|
||||
>
|
||||
<User className="h-4 w-4" />
|
||||
Perfil
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
navigate('/app/settings');
|
||||
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-tertiary)] transition-colors"
|
||||
>
|
||||
<Settings className="h-4 w-4" />
|
||||
Configuración
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="border-t border-[var(--border-primary)] pt-1">
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
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
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
</aside>
|
||||
</>
|
||||
);
|
||||
|
||||
78
frontend/src/components/ui/LanguageSelector.tsx
Normal file
78
frontend/src/components/ui/LanguageSelector.tsx
Normal file
@@ -0,0 +1,78 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
languageConfig,
|
||||
supportedLanguages,
|
||||
type SupportedLanguage
|
||||
} from '../../locales';
|
||||
import { useLanguageSwitcher } from '../../hooks/useLanguageSwitcher';
|
||||
import { Select } from './Select';
|
||||
|
||||
interface LanguageSelectorProps {
|
||||
className?: string;
|
||||
variant?: 'compact' | 'full';
|
||||
}
|
||||
|
||||
export function LanguageSelector({
|
||||
className,
|
||||
variant = 'full'
|
||||
}: LanguageSelectorProps) {
|
||||
const { currentLanguage, changeLanguage, isChanging } = useLanguageSwitcher();
|
||||
|
||||
const languageOptions = supportedLanguages.map(lang => ({
|
||||
value: lang,
|
||||
label: variant === 'compact'
|
||||
? `${languageConfig[lang].flag} ${languageConfig[lang].code.toUpperCase()}`
|
||||
: `${languageConfig[lang].flag} ${languageConfig[lang].nativeName}`,
|
||||
}));
|
||||
|
||||
const handleLanguageChange = (value: string | number | Array<string | number>) => {
|
||||
if (typeof value === 'string') {
|
||||
const newLanguage = value as SupportedLanguage;
|
||||
changeLanguage(newLanguage);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Select
|
||||
value={currentLanguage}
|
||||
onChange={handleLanguageChange}
|
||||
options={languageOptions}
|
||||
className={className}
|
||||
placeholder="Select language"
|
||||
disabled={isChanging}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// Compact version for headers/toolbars
|
||||
export function CompactLanguageSelector({ className }: { className?: string }) {
|
||||
return (
|
||||
<LanguageSelector
|
||||
variant="compact"
|
||||
className={className}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// Hook for language-related utilities
|
||||
export function useLanguageUtils() {
|
||||
const { currentLanguage, changeLanguage } = useLanguageSwitcher();
|
||||
|
||||
const getCurrentLanguageConfig = () => languageConfig[currentLanguage];
|
||||
|
||||
const isRTL = () => getCurrentLanguageConfig().rtl;
|
||||
|
||||
const getLanguageFlag = () => getCurrentLanguageConfig().flag;
|
||||
|
||||
const getLanguageName = () => getCurrentLanguageConfig().nativeName;
|
||||
|
||||
return {
|
||||
currentLanguage,
|
||||
languageConfig: getCurrentLanguageConfig(),
|
||||
isRTL,
|
||||
getLanguageFlag,
|
||||
getLanguageName,
|
||||
changeLanguage,
|
||||
availableLanguages: supportedLanguages,
|
||||
};
|
||||
}
|
||||
@@ -95,12 +95,15 @@ export const NotificationPanel: React.FC<NotificationPanelProps> = ({
|
||||
if (!isOpen) return null;
|
||||
|
||||
const unreadNotifications = notifications.filter(n => !n.read);
|
||||
const isMobile = window.innerWidth < 768;
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Backdrop */}
|
||||
<div
|
||||
className="fixed inset-0 z-40 bg-black/20"
|
||||
className={`fixed inset-0 z-[9998] transition-all duration-300 ${
|
||||
isMobile ? 'bg-black/50 backdrop-blur-sm' : 'bg-black/20'
|
||||
}`}
|
||||
onClick={onClose}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
@@ -108,51 +111,66 @@ export const NotificationPanel: React.FC<NotificationPanelProps> = ({
|
||||
{/* Panel */}
|
||||
<div
|
||||
className={clsx(
|
||||
"absolute right-0 top-full mt-2 w-96 max-w-sm bg-[var(--bg-primary)] border border-[var(--border-primary)] rounded-lg shadow-xl z-50 max-h-96 flex flex-col",
|
||||
"fixed z-[9999] bg-[var(--bg-primary)] border border-[var(--border-primary)] shadow-2xl flex flex-col transition-all duration-300 ease-out",
|
||||
isMobile
|
||||
? "inset-x-0 bottom-0 rounded-t-2xl max-h-[85vh]"
|
||||
: "right-0 top-full mt-2 w-96 max-w-sm rounded-xl shadow-xl max-h-96",
|
||||
className
|
||||
)}
|
||||
>
|
||||
{/* Mobile Handle */}
|
||||
{isMobile && (
|
||||
<div className="flex justify-center py-3">
|
||||
<div className="w-10 h-1 bg-[var(--border-secondary)] rounded-full" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between p-4 border-b border-[var(--border-primary)]">
|
||||
<div className={`flex items-center justify-between border-b border-[var(--border-primary)] ${
|
||||
isMobile ? 'px-6 py-4' : 'px-4 py-3'
|
||||
}`}>
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="font-semibold text-sm" style={{ color: 'var(--text-primary)' }}>
|
||||
<h3 className={`font-semibold text-[var(--text-primary)] ${
|
||||
isMobile ? 'text-lg' : 'text-sm'
|
||||
}`}>
|
||||
Notificaciones
|
||||
</h3>
|
||||
{unreadNotifications.length > 0 && (
|
||||
<Badge variant="info" size="sm">
|
||||
<Badge variant="info" size={isMobile ? "md" : "sm"}>
|
||||
{unreadNotifications.length} nuevas
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1">
|
||||
<div className="flex items-center gap-2">
|
||||
{unreadNotifications.length > 0 && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
size={isMobile ? "md" : "sm"}
|
||||
onClick={onMarkAllAsRead}
|
||||
className="h-6 px-2 text-xs"
|
||||
className={`${isMobile ? 'px-3 py-2 text-sm' : 'h-6 px-2 text-xs'}`}
|
||||
>
|
||||
<Check className={`${isMobile ? 'w-4 h-4 mr-2' : 'w-3 h-3 mr-1'}`} />
|
||||
Marcar todas
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
size={isMobile ? "md" : "sm"}
|
||||
onClick={onClose}
|
||||
className="h-6 w-6 p-0"
|
||||
className={`${isMobile ? 'p-2' : 'h-6 w-6 p-0'} hover:bg-[var(--bg-secondary)] rounded-full`}
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
<X className={`${isMobile ? 'w-5 h-5' : 'w-3 h-3'}`} />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Notifications List */}
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
<div className={`flex-1 overflow-y-auto ${isMobile ? 'px-2 py-2' : ''}`}>
|
||||
{notifications.length === 0 ? (
|
||||
<div className="p-8 text-center">
|
||||
<CheckCircle className="w-8 h-8 mx-auto mb-2" style={{ color: 'var(--color-success)' }} />
|
||||
<p className="text-sm" style={{ color: 'var(--text-secondary)' }}>
|
||||
<div className={`text-center ${isMobile ? 'py-12 px-6' : 'p-8'}`}>
|
||||
<CheckCircle className={`mx-auto mb-3 ${isMobile ? 'w-12 h-12' : 'w-8 h-8'}`} style={{ color: 'var(--color-success)' }} />
|
||||
<p className={`text-[var(--text-secondary)] ${isMobile ? 'text-base' : 'text-sm'}`}>
|
||||
No hay notificaciones
|
||||
</p>
|
||||
</div>
|
||||
@@ -165,18 +183,19 @@ export const NotificationPanel: React.FC<NotificationPanelProps> = ({
|
||||
<div
|
||||
key={notification.id}
|
||||
className={clsx(
|
||||
"p-3 hover:bg-[var(--bg-secondary)] transition-colors",
|
||||
"transition-colors hover:bg-[var(--bg-secondary)] active:bg-[var(--bg-tertiary)]",
|
||||
isMobile ? 'px-4 py-4 mx-2 my-1 rounded-lg' : 'p-3',
|
||||
!notification.read && "bg-[var(--color-info)]/5"
|
||||
)}
|
||||
>
|
||||
<div className="flex gap-3">
|
||||
<div className={`flex gap-${isMobile ? '4' : '3'}`}>
|
||||
{/* Icon */}
|
||||
<div
|
||||
className="flex-shrink-0 p-1 rounded-full mt-0.5"
|
||||
className={`flex-shrink-0 rounded-full mt-0.5 ${isMobile ? 'p-2' : 'p-1'}`}
|
||||
style={{ backgroundColor: getSeverityColor(notification.severity) + '15' }}
|
||||
>
|
||||
<SeverityIcon
|
||||
className="w-3 h-3"
|
||||
className={`${isMobile ? 'w-5 h-5' : 'w-3 h-3'}`}
|
||||
style={{ color: getSeverityColor(notification.severity) }}
|
||||
/>
|
||||
</div>
|
||||
@@ -184,50 +203,56 @@ export const NotificationPanel: React.FC<NotificationPanelProps> = ({
|
||||
{/* Content */}
|
||||
<div className="flex-1 min-w-0">
|
||||
{/* Header */}
|
||||
<div className="flex items-start justify-between gap-2 mb-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant={getSeverityBadge(notification.severity)} size="sm">
|
||||
<div className={`flex items-start justify-between gap-2 ${isMobile ? 'mb-2' : 'mb-1'}`}>
|
||||
<div className={`flex items-center gap-2 ${isMobile ? 'flex-wrap' : ''}`}>
|
||||
<Badge variant={getSeverityBadge(notification.severity)} size={isMobile ? "md" : "sm"}>
|
||||
{notification.severity.toUpperCase()}
|
||||
</Badge>
|
||||
<Badge variant="secondary" size="sm">
|
||||
<Badge variant="secondary" size={isMobile ? "md" : "sm"}>
|
||||
{notification.item_type === 'alert' ? 'Alerta' : 'Recomendación'}
|
||||
</Badge>
|
||||
</div>
|
||||
<span className="text-xs font-medium" style={{ color: 'var(--text-secondary)' }}>
|
||||
<span className={`font-medium text-[var(--text-secondary)] ${isMobile ? 'text-sm' : 'text-xs'}`}>
|
||||
{formatTimestamp(notification.timestamp)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Title */}
|
||||
<p className="text-sm font-medium mb-1 leading-tight" style={{ color: 'var(--text-primary)' }}>
|
||||
<p className={`font-medium leading-tight text-[var(--text-primary)] ${
|
||||
isMobile ? 'text-base mb-2' : 'text-sm mb-1'
|
||||
}`}>
|
||||
{notification.title}
|
||||
</p>
|
||||
|
||||
{/* Message */}
|
||||
<p className="text-xs leading-relaxed mb-2" style={{ color: 'var(--text-secondary)' }}>
|
||||
<p className={`leading-relaxed text-[var(--text-secondary)] ${
|
||||
isMobile ? 'text-sm mb-4' : 'text-xs mb-2'
|
||||
}`}>
|
||||
{notification.message}
|
||||
</p>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center gap-1">
|
||||
<div className={`flex items-center gap-2 ${isMobile ? 'flex-col sm:flex-row' : ''}`}>
|
||||
{!notification.read && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
size={isMobile ? "md" : "sm"}
|
||||
onClick={() => onMarkAsRead(notification.id)}
|
||||
className="h-6 px-2 text-xs"
|
||||
className={`${isMobile ? 'w-full sm:w-auto px-4 py-2 text-sm' : 'h-6 px-2 text-xs'}`}
|
||||
>
|
||||
<Check className="w-3 h-3 mr-1" />
|
||||
<Check className={`${isMobile ? 'w-4 h-4 mr-2' : 'w-3 h-3 mr-1'}`} />
|
||||
Marcar como leído
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
size={isMobile ? "md" : "sm"}
|
||||
onClick={() => onRemoveNotification(notification.id)}
|
||||
className="h-6 px-2 text-xs text-red-600 hover:text-red-700"
|
||||
className={`text-[var(--color-error)] hover:text-[var(--color-error-dark)] ${
|
||||
isMobile ? 'w-full sm:w-auto px-4 py-2 text-sm' : 'h-6 px-2 text-xs'
|
||||
}`}
|
||||
>
|
||||
<Trash2 className="w-3 h-3 mr-1" />
|
||||
<Trash2 className={`${isMobile ? 'w-4 h-4 mr-2' : 'w-3 h-3 mr-1'}`} />
|
||||
Eliminar
|
||||
</Button>
|
||||
</div>
|
||||
@@ -242,13 +267,18 @@ export const NotificationPanel: React.FC<NotificationPanelProps> = ({
|
||||
|
||||
{/* Footer */}
|
||||
{notifications.length > 0 && (
|
||||
<div className="p-3 border-t border-[var(--border-primary)] bg-[var(--bg-secondary)]">
|
||||
<div className={`border-t border-[var(--border-primary)] bg-[var(--bg-secondary)] ${
|
||||
isMobile ? 'px-6 py-4' : 'p-3'
|
||||
}`}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
size={isMobile ? "md" : "sm"}
|
||||
onClick={onClearAll}
|
||||
className="w-full text-xs text-red-600 hover:text-red-700"
|
||||
className={`w-full text-[var(--color-error)] hover:text-[var(--color-error-dark)] ${
|
||||
isMobile ? 'px-4 py-3 text-sm' : 'text-xs'
|
||||
}`}
|
||||
>
|
||||
<Trash2 className={`${isMobile ? 'w-4 h-4 mr-2' : 'w-3 h-3 mr-1'}`} />
|
||||
Limpiar todas las notificaciones
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -3,7 +3,7 @@ import { createPortal } from 'react-dom';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useTenant } from '../../stores/tenant.store';
|
||||
import { useToast } from '../../hooks/ui/useToast';
|
||||
import { ChevronDown, Building2, Check, AlertCircle, Plus } from 'lucide-react';
|
||||
import { ChevronDown, Building2, Check, AlertCircle, Plus, X } from 'lucide-react';
|
||||
|
||||
interface TenantSwitcherProps {
|
||||
className?: string;
|
||||
@@ -92,41 +92,35 @@ export const TenantSwitcher: React.FC<TenantSwitcherProps> = ({
|
||||
// Calculate dropdown position
|
||||
const calculateDropdownPosition = () => {
|
||||
if (!buttonRef.current) return;
|
||||
|
||||
|
||||
const buttonRect = buttonRef.current.getBoundingClientRect();
|
||||
const viewportWidth = window.innerWidth;
|
||||
const viewportHeight = window.innerHeight;
|
||||
const isMobile = viewportWidth < 768; // md breakpoint
|
||||
|
||||
|
||||
if (isMobile) {
|
||||
// On mobile, use full width with margins and position from top
|
||||
// Check if dropdown would go off bottom of screen
|
||||
const dropdownHeight = Math.min(400, viewportHeight * 0.7); // Max 70vh
|
||||
const spaceBelow = viewportHeight - buttonRect.bottom - 8;
|
||||
const shouldPositionAbove = spaceBelow < dropdownHeight && buttonRect.top > dropdownHeight;
|
||||
|
||||
// On mobile, use a modal-style overlay
|
||||
setDropdownPosition({
|
||||
top: shouldPositionAbove ? buttonRect.top - dropdownHeight - 8 : buttonRect.bottom + 8,
|
||||
left: 16, // 1rem margin from screen edge
|
||||
right: 16, // For full width calculation
|
||||
width: viewportWidth - 32, // Full width minus margins
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: viewportWidth,
|
||||
isMobile: true,
|
||||
});
|
||||
} else {
|
||||
// Desktop positioning - align right edge of dropdown with right edge of button
|
||||
const dropdownWidth = 320; // w-80 (20rem * 16px) - slightly wider for desktop
|
||||
const dropdownWidth = 360; // Wider for better content display
|
||||
let left = buttonRect.right - dropdownWidth;
|
||||
|
||||
|
||||
// Ensure dropdown doesn't go off the left edge of the screen
|
||||
if (left < 16) {
|
||||
left = 16;
|
||||
}
|
||||
|
||||
|
||||
// Ensure dropdown doesn't go off the right edge
|
||||
if (left + dropdownWidth > viewportWidth - 16) {
|
||||
left = viewportWidth - dropdownWidth - 16;
|
||||
}
|
||||
|
||||
|
||||
setDropdownPosition({
|
||||
top: buttonRect.bottom + 8,
|
||||
left: left,
|
||||
@@ -175,7 +169,7 @@ export const TenantSwitcher: React.FC<TenantSwitcherProps> = ({
|
||||
// Handle creating new tenant
|
||||
const handleCreateNewTenant = () => {
|
||||
setIsOpen(false);
|
||||
navigate('/app/onboarding');
|
||||
navigate('/app/onboarding?new=true');
|
||||
};
|
||||
|
||||
// Don't render if no tenants available
|
||||
@@ -200,122 +194,221 @@ export const TenantSwitcher: React.FC<TenantSwitcherProps> = ({
|
||||
ref={buttonRef}
|
||||
onClick={toggleDropdown}
|
||||
disabled={isLoading}
|
||||
className="flex items-center space-x-2 px-3 py-2 text-sm font-medium text-text-primary bg-bg-secondary hover:bg-bg-tertiary border border-border-secondary rounded-lg transition-colors focus:outline-none focus:ring-2 focus:ring-color-primary focus:ring-opacity-20 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
className={`
|
||||
flex items-center justify-between w-full
|
||||
${showLabel ? 'px-3 py-2.5' : 'px-2.5 py-2.5 justify-center'}
|
||||
text-sm font-medium text-[var(--text-primary)]
|
||||
bg-[var(--bg-secondary)] hover:bg-[var(--bg-tertiary)]
|
||||
border border-[var(--border-secondary)]
|
||||
rounded-lg transition-all duration-200
|
||||
focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)]/20
|
||||
active:scale-[0.98] active:bg-[var(--bg-tertiary)]
|
||||
disabled:opacity-50 disabled:cursor-not-allowed
|
||||
min-h-[44px] touch-manipulation
|
||||
${isOpen ? 'ring-2 ring-[var(--color-primary)]/20 bg-[var(--bg-tertiary)]' : ''}
|
||||
`}
|
||||
aria-expanded={isOpen}
|
||||
aria-haspopup="listbox"
|
||||
aria-label="Switch tenant"
|
||||
>
|
||||
<Building2 className="w-4 h-4 text-text-secondary" />
|
||||
<div className="flex items-center space-x-2 min-w-0 flex-1">
|
||||
<div className={`
|
||||
flex-shrink-0 w-8 h-8 rounded-full flex items-center justify-center
|
||||
${currentTenant ? 'bg-[var(--color-primary)]/10' : 'bg-[var(--bg-tertiary)]'}
|
||||
`}>
|
||||
<Building2 className={`w-4 h-4 ${currentTenant ? 'text-[var(--color-primary)]' : 'text-[var(--text-tertiary)]'}`} />
|
||||
</div>
|
||||
{showLabel && (
|
||||
<div className="flex-1 min-w-0 text-left">
|
||||
<div className="text-sm font-medium text-[var(--text-primary)] truncate">
|
||||
{currentTenant?.name || 'Select Organization'}
|
||||
</div>
|
||||
{currentTenant?.city && (
|
||||
<div className="text-xs text-[var(--text-secondary)] truncate">
|
||||
{currentTenant.city}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{showLabel && (
|
||||
<span className="hidden sm:block max-w-32 truncate">
|
||||
{currentTenant?.name || 'Select Tenant'}
|
||||
</span>
|
||||
<ChevronDown
|
||||
className={`w-4 h-4 text-[var(--text-secondary)] transition-transform duration-200 flex-shrink-0 ml-2 ${
|
||||
isOpen ? 'rotate-180' : ''
|
||||
}`}
|
||||
/>
|
||||
)}
|
||||
<ChevronDown
|
||||
className={`w-4 h-4 text-text-secondary transition-transform ${
|
||||
isOpen ? 'rotate-180' : ''
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
|
||||
{/* Dropdown Menu - Rendered in portal to avoid stacking context issues */}
|
||||
{isOpen && createPortal(
|
||||
<div
|
||||
ref={dropdownRef}
|
||||
className={`fixed bg-bg-primary border border-border-secondary rounded-lg shadow-lg z-[9999] ${
|
||||
dropdownPosition.isMobile ? 'mx-4' : ''
|
||||
}`}
|
||||
style={{
|
||||
top: `${dropdownPosition.top}px`,
|
||||
left: `${dropdownPosition.left}px`,
|
||||
width: `${dropdownPosition.width}px`,
|
||||
maxHeight: dropdownPosition.isMobile ? '70vh' : '80vh',
|
||||
}}
|
||||
role="listbox"
|
||||
aria-label="Available tenants"
|
||||
>
|
||||
{/* Header */}
|
||||
<div className={`border-b border-border-primary ${dropdownPosition.isMobile ? 'px-4 py-3' : 'px-3 py-2'}`}>
|
||||
<h3 className={`font-semibold text-text-primary ${dropdownPosition.isMobile ? 'text-base' : 'text-sm'}`}>
|
||||
Organizations
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
{/* Error State */}
|
||||
{error && (
|
||||
<div className="px-3 py-2 border-b border-border-primary">
|
||||
<div className="flex items-center space-x-2 text-color-error text-xs">
|
||||
<AlertCircle className="w-4 h-4 flex-shrink-0" />
|
||||
<span>{error}</span>
|
||||
<button
|
||||
onClick={handleRetry}
|
||||
className="ml-auto text-color-primary hover:text-color-primary-dark underline"
|
||||
>
|
||||
Retry
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<>
|
||||
{/* Mobile Backdrop */}
|
||||
{dropdownPosition.isMobile && (
|
||||
<div
|
||||
className="fixed inset-0 bg-black/50 backdrop-blur-sm z-[9998]"
|
||||
onClick={() => setIsOpen(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Tenant List */}
|
||||
<div className={`overflow-y-auto ${dropdownPosition.isMobile ? 'max-h-[60vh]' : 'max-h-80'}`}>
|
||||
{availableTenants.map((tenant) => (
|
||||
<button
|
||||
key={tenant.id}
|
||||
onClick={() => handleTenantSwitch(tenant.id)}
|
||||
disabled={isLoading}
|
||||
className={`w-full text-left hover:bg-bg-secondary focus:bg-bg-secondary focus:outline-none disabled:opacity-50 disabled:cursor-not-allowed transition-colors ${
|
||||
dropdownPosition.isMobile ? 'px-4 py-4 active:bg-bg-tertiary' : 'px-3 py-3'
|
||||
}`}
|
||||
role="option"
|
||||
aria-selected={tenant.id === currentTenant?.id}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className={`w-8 h-8 rounded-full flex items-center justify-center flex-shrink-0 ${
|
||||
tenant.id === currentTenant?.id
|
||||
? 'bg-color-primary text-white'
|
||||
: 'bg-color-primary/10 text-color-primary'
|
||||
}`}>
|
||||
<Building2 className="w-4 h-4" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm font-medium text-text-primary truncate">
|
||||
{tenant.name}
|
||||
</p>
|
||||
<p className="text-xs text-text-secondary truncate">
|
||||
{tenant.city}
|
||||
</p>
|
||||
{/* Dropdown Content */}
|
||||
<div
|
||||
ref={dropdownRef}
|
||||
className={`
|
||||
fixed z-[9999] bg-[var(--bg-primary)] border border-[var(--border-primary)]
|
||||
shadow-2xl transition-all duration-300 ease-out
|
||||
${dropdownPosition.isMobile
|
||||
? 'inset-x-0 bottom-0 rounded-t-2xl max-h-[85vh]'
|
||||
: 'rounded-xl shadow-xl'
|
||||
}
|
||||
`}
|
||||
style={
|
||||
dropdownPosition.isMobile
|
||||
? {}
|
||||
: {
|
||||
top: `${dropdownPosition.top}px`,
|
||||
left: `${dropdownPosition.left}px`,
|
||||
width: `${dropdownPosition.width}px`,
|
||||
maxHeight: '400px',
|
||||
}
|
||||
}
|
||||
role="listbox"
|
||||
aria-label="Available tenants"
|
||||
>
|
||||
{/* Mobile Handle */}
|
||||
{dropdownPosition.isMobile && (
|
||||
<div className="flex justify-center py-3">
|
||||
<div className="w-10 h-1 bg-[var(--border-secondary)] rounded-full" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Header */}
|
||||
<div className={`
|
||||
flex items-center justify-between
|
||||
border-b border-[var(--border-primary)]
|
||||
${dropdownPosition.isMobile ? 'px-6 py-4' : 'px-4 py-3'}
|
||||
`}>
|
||||
<h3 className={`font-semibold text-[var(--text-primary)] ${
|
||||
dropdownPosition.isMobile ? 'text-lg' : 'text-sm'
|
||||
}`}>
|
||||
Organizations
|
||||
</h3>
|
||||
{dropdownPosition.isMobile && (
|
||||
<button
|
||||
onClick={() => setIsOpen(false)}
|
||||
className="p-2 hover:bg-[var(--bg-secondary)] rounded-full transition-colors"
|
||||
>
|
||||
<X className="w-5 h-5 text-[var(--text-secondary)]" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Error State */}
|
||||
{error && (
|
||||
<div className={`border-b border-[var(--border-primary)] ${
|
||||
dropdownPosition.isMobile ? 'px-6 py-4' : 'px-4 py-3'
|
||||
}`}>
|
||||
<div className="flex items-center space-x-3 text-[var(--color-error)]">
|
||||
<AlertCircle className="w-5 h-5 flex-shrink-0" />
|
||||
<span className="text-sm flex-1">{error}</span>
|
||||
<button
|
||||
onClick={handleRetry}
|
||||
className="text-[var(--color-primary)] hover:text-[var(--color-primary-dark)] underline text-sm font-medium"
|
||||
>
|
||||
Retry
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Tenant List */}
|
||||
<div className={`
|
||||
overflow-y-auto
|
||||
${dropdownPosition.isMobile ? 'max-h-[50vh] px-2 py-2' : 'max-h-72 py-1'}
|
||||
`}>
|
||||
{availableTenants.map((tenant) => (
|
||||
<button
|
||||
key={tenant.id}
|
||||
onClick={() => handleTenantSwitch(tenant.id)}
|
||||
disabled={isLoading}
|
||||
className={`
|
||||
w-full text-left rounded-lg transition-all duration-200
|
||||
hover:bg-[var(--bg-secondary)] focus:bg-[var(--bg-secondary)]
|
||||
active:scale-[0.98] active:bg-[var(--bg-tertiary)]
|
||||
focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)]/20
|
||||
disabled:opacity-50 disabled:cursor-not-allowed
|
||||
${dropdownPosition.isMobile ? 'px-4 py-4 mx-2 my-1 min-h-[60px]' : 'px-3 py-3 mx-1 my-0.5'}
|
||||
${tenant.id === currentTenant?.id ? 'bg-[var(--color-primary)]/5 ring-1 ring-[var(--color-primary)]/20' : ''}
|
||||
`}
|
||||
role="option"
|
||||
aria-selected={tenant.id === currentTenant?.id}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className={`
|
||||
${dropdownPosition.isMobile ? 'w-12 h-12' : 'w-10 h-10'}
|
||||
rounded-full flex items-center justify-center flex-shrink-0 transition-colors
|
||||
${tenant.id === currentTenant?.id
|
||||
? 'bg-[var(--color-primary)] text-white'
|
||||
: 'bg-[var(--color-primary)]/10 text-[var(--color-primary)]'
|
||||
}
|
||||
`}>
|
||||
<Building2 className={`${dropdownPosition.isMobile ? 'w-6 h-6' : 'w-5 h-5'}`} />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className={`font-medium text-[var(--text-primary)] truncate ${
|
||||
dropdownPosition.isMobile ? 'text-base' : 'text-sm'
|
||||
}`}>
|
||||
{tenant.name}
|
||||
</p>
|
||||
{tenant.city && (
|
||||
<p className={`text-[var(--text-secondary)] truncate ${
|
||||
dropdownPosition.isMobile ? 'text-sm' : 'text-xs'
|
||||
}`}>
|
||||
{tenant.city}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{tenant.id === currentTenant?.id && (
|
||||
<Check className={`text-[var(--color-success)] flex-shrink-0 ml-3 ${
|
||||
dropdownPosition.isMobile ? 'w-6 h-6' : 'w-5 h-5'
|
||||
}`} />
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{tenant.id === currentTenant?.id && (
|
||||
<Check className="w-4 h-4 text-color-success flex-shrink-0 ml-2" />
|
||||
)}
|
||||
</div>
|
||||
{/* Footer */}
|
||||
<div className={`
|
||||
border-t border-[var(--border-primary)]
|
||||
${dropdownPosition.isMobile ? 'px-6 py-6' : 'px-4 py-3'}
|
||||
`}>
|
||||
<button
|
||||
onClick={handleCreateNewTenant}
|
||||
className={`
|
||||
w-full flex items-center justify-center gap-3
|
||||
${dropdownPosition.isMobile ? 'px-6 py-4 text-base' : 'px-4 py-2.5 text-sm'}
|
||||
text-white font-semibold
|
||||
bg-gradient-to-r from-[var(--color-primary)] to-[var(--color-primary-dark)]
|
||||
hover:from-[var(--color-primary-dark)] hover:to-[var(--color-primary)]
|
||||
shadow-md hover:shadow-lg
|
||||
rounded-lg transition-all duration-200
|
||||
focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)]/30
|
||||
active:scale-[0.98]
|
||||
border border-[var(--color-primary)]/30
|
||||
`}
|
||||
>
|
||||
<Plus className={`${dropdownPosition.isMobile ? 'w-5 h-5' : 'w-4 h-4'}`} />
|
||||
<span>Agregar Nueva Organización</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className={`border-t border-border-primary bg-bg-secondary rounded-b-lg ${
|
||||
dropdownPosition.isMobile ? 'px-4 py-3' : 'px-3 py-2'
|
||||
}`}>
|
||||
<button
|
||||
onClick={handleCreateNewTenant}
|
||||
className={`w-full flex items-center justify-center gap-2 ${
|
||||
dropdownPosition.isMobile ? 'px-4 py-3' : 'px-3 py-2'
|
||||
} text-color-primary hover:text-color-primary-dark hover:bg-bg-tertiary rounded-lg transition-colors focus:outline-none focus:ring-2 focus:ring-color-primary focus:ring-opacity-20`}
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
<span className={`font-medium ${dropdownPosition.isMobile ? 'text-sm' : 'text-xs'}`}>
|
||||
Add New Organization
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>,
|
||||
</>,
|
||||
document.body
|
||||
)}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user