Files
bakery-ia/frontend/src/components/ui/TenantSwitcher.tsx

330 lines
11 KiB
TypeScript
Raw Normal View History

import React, { useState, useRef, useEffect } from 'react';
2025-09-12 23:58:26 +02:00
import { createPortal } from 'react-dom';
2025-09-21 22:56:55 +02:00
import { useNavigate } from 'react-router-dom';
import { useTenant } from '../../stores/tenant.store';
import { useToast } from '../../hooks/ui/useToast';
2025-09-21 22:56:55 +02:00
import { ChevronDown, Building2, Check, AlertCircle, Plus } from 'lucide-react';
interface TenantSwitcherProps {
className?: string;
showLabel?: boolean;
}
export const TenantSwitcher: React.FC<TenantSwitcherProps> = ({
className = '',
showLabel = true,
}) => {
2025-09-21 22:56:55 +02:00
const navigate = useNavigate();
const [isOpen, setIsOpen] = useState(false);
2025-09-12 23:58:26 +02:00
const [dropdownPosition, setDropdownPosition] = useState<{
top: number;
left: number;
right?: number;
width: number;
isMobile: boolean;
}>({ top: 0, left: 0, width: 288, isMobile: false });
const dropdownRef = useRef<HTMLDivElement>(null);
const buttonRef = useRef<HTMLButtonElement>(null);
2025-09-21 22:56:55 +02:00
const {
currentTenant,
availableTenants,
isLoading,
error,
switchTenant,
loadUserTenants,
clearError,
} = useTenant();
2025-09-21 22:56:55 +02:00
const { success: showSuccessToast, error: showErrorToast } = useToast();
// Load tenants on mount
useEffect(() => {
if (!availableTenants) {
loadUserTenants();
}
}, [availableTenants, loadUserTenants]);
// Handle click outside to close dropdown
useEffect(() => {
2025-09-12 23:58:26 +02:00
const handleClickOutside = (event: MouseEvent | TouchEvent) => {
const target = event.target as Node;
if (
dropdownRef.current &&
2025-09-12 23:58:26 +02:00
!dropdownRef.current.contains(target) &&
!buttonRef.current?.contains(target)
) {
setIsOpen(false);
}
};
2025-09-12 23:58:26 +02:00
const handleEscape = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
setIsOpen(false);
}
};
if (isOpen) {
document.addEventListener('mousedown', handleClickOutside);
2025-09-12 23:58:26 +02:00
document.addEventListener('touchstart', handleClickOutside);
document.addEventListener('keydown', handleEscape);
}
return () => {
document.removeEventListener('mousedown', handleClickOutside);
2025-09-12 23:58:26 +02:00
document.removeEventListener('touchstart', handleClickOutside);
document.removeEventListener('keydown', handleEscape);
};
}, [isOpen]);
2025-09-12 23:58:26 +02:00
// Recalculate position on window resize
useEffect(() => {
const handleResize = () => {
if (isOpen) {
calculateDropdownPosition();
}
};
window.addEventListener('resize', handleResize);
return () => window.removeEventListener('resize', handleResize);
}, [isOpen]);
// 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;
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
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
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,
width: dropdownWidth,
isMobile: false,
});
}
};
// Handle dropdown open/close
const toggleDropdown = () => {
if (!isOpen) {
calculateDropdownPosition();
}
setIsOpen(!isOpen);
};
// Handle tenant switch
const handleTenantSwitch = async (tenantId: string) => {
if (tenantId === currentTenant?.id) {
setIsOpen(false);
return;
}
const success = await switchTenant(tenantId);
setIsOpen(false);
if (success) {
const newTenant = availableTenants?.find(t => t.id === tenantId);
showSuccessToast(`Switched to ${newTenant?.name}`, {
title: 'Tenant Switched'
});
} else {
showErrorToast(error || 'Failed to switch tenant', {
title: 'Switch Failed'
});
}
};
// Handle retry loading tenants
const handleRetry = () => {
clearError();
loadUserTenants();
};
2025-09-21 22:56:55 +02:00
// Handle creating new tenant
const handleCreateNewTenant = () => {
setIsOpen(false);
navigate('/app/onboarding');
};
// Don't render if no tenants available
if (!availableTenants || availableTenants.length === 0) {
return null;
}
// Don't render if only one tenant
if (availableTenants.length === 1) {
return showLabel ? (
<div className={`flex items-center space-x-2 text-text-secondary ${className}`}>
<Building2 className="w-4 h-4" />
<span className="text-sm font-medium">{currentTenant?.name}</span>
</div>
) : null;
}
return (
<div className={`relative ${className}`}>
{/* Trigger Button */}
<button
ref={buttonRef}
2025-09-12 23:58:26 +02:00
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"
aria-expanded={isOpen}
aria-haspopup="listbox"
aria-label="Switch tenant"
>
<Building2 className="w-4 h-4 text-text-secondary" />
{showLabel && (
<span className="hidden sm:block max-w-32 truncate">
{currentTenant?.name || 'Select Tenant'}
</span>
)}
<ChevronDown
className={`w-4 h-4 text-text-secondary transition-transform ${
isOpen ? 'rotate-180' : ''
}`}
/>
</button>
2025-09-12 23:58:26 +02:00
{/* Dropdown Menu - Rendered in portal to avoid stacking context issues */}
{isOpen && createPortal(
<div
ref={dropdownRef}
2025-09-12 23:58:26 +02:00
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 */}
2025-09-12 23:58:26 +02:00
<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'}`}>
2025-09-21 22:56:55 +02:00
Organizations
2025-09-12 23:58:26 +02:00
</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>
)}
{/* Tenant List */}
2025-09-12 23:58:26 +02:00
<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}
2025-09-12 23:58:26 +02:00
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">
2025-09-21 22:56:55 +02:00
<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">
2025-09-21 22:56:55 +02:00
{tenant.city}
</p>
</div>
</div>
</div>
2025-09-21 22:56:55 +02:00
{tenant.id === currentTenant?.id && (
<Check className="w-4 h-4 text-color-success flex-shrink-0 ml-2" />
)}
</div>
</button>
))}
</div>
{/* Footer */}
2025-09-12 23:58:26 +02:00
<div className={`border-t border-border-primary bg-bg-secondary rounded-b-lg ${
dropdownPosition.isMobile ? 'px-4 py-3' : 'px-3 py-2'
}`}>
2025-09-21 22:56:55 +02:00
<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>
2025-09-12 23:58:26 +02:00
</div>,
document.body
)}
{/* Loading Overlay */}
{isLoading && (
<div className="absolute inset-0 bg-bg-primary/50 rounded-lg flex items-center justify-center">
<div className="w-4 h-4 border-2 border-color-primary border-t-transparent rounded-full animate-spin"></div>
</div>
)}
</div>
);
};