330 lines
11 KiB
TypeScript
330 lines
11 KiB
TypeScript
import React, { useState, useRef, useEffect } from 'react';
|
|
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';
|
|
|
|
interface TenantSwitcherProps {
|
|
className?: string;
|
|
showLabel?: boolean;
|
|
}
|
|
|
|
export const TenantSwitcher: React.FC<TenantSwitcherProps> = ({
|
|
className = '',
|
|
showLabel = true,
|
|
}) => {
|
|
const navigate = useNavigate();
|
|
const [isOpen, setIsOpen] = useState(false);
|
|
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);
|
|
|
|
const {
|
|
currentTenant,
|
|
availableTenants,
|
|
isLoading,
|
|
error,
|
|
switchTenant,
|
|
loadUserTenants,
|
|
clearError,
|
|
} = useTenant();
|
|
|
|
const { success: showSuccessToast, error: showErrorToast } = useToast();
|
|
|
|
// Load tenants on mount
|
|
useEffect(() => {
|
|
if (!availableTenants) {
|
|
loadUserTenants();
|
|
}
|
|
}, [availableTenants, loadUserTenants]);
|
|
|
|
// Handle click outside to close dropdown
|
|
useEffect(() => {
|
|
const handleClickOutside = (event: MouseEvent | TouchEvent) => {
|
|
const target = event.target as Node;
|
|
if (
|
|
dropdownRef.current &&
|
|
!dropdownRef.current.contains(target) &&
|
|
!buttonRef.current?.contains(target)
|
|
) {
|
|
setIsOpen(false);
|
|
}
|
|
};
|
|
|
|
const handleEscape = (event: KeyboardEvent) => {
|
|
if (event.key === 'Escape') {
|
|
setIsOpen(false);
|
|
}
|
|
};
|
|
|
|
if (isOpen) {
|
|
document.addEventListener('mousedown', handleClickOutside);
|
|
document.addEventListener('touchstart', handleClickOutside);
|
|
document.addEventListener('keydown', handleEscape);
|
|
}
|
|
|
|
return () => {
|
|
document.removeEventListener('mousedown', handleClickOutside);
|
|
document.removeEventListener('touchstart', handleClickOutside);
|
|
document.removeEventListener('keydown', handleEscape);
|
|
};
|
|
}, [isOpen]);
|
|
|
|
// 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();
|
|
};
|
|
|
|
// 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}
|
|
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>
|
|
|
|
{/* 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>
|
|
)}
|
|
|
|
{/* 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>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{tenant.id === currentTenant?.id && (
|
|
<Check className="w-4 h-4 text-color-success flex-shrink-0 ml-2" />
|
|
)}
|
|
</div>
|
|
</button>
|
|
))}
|
|
</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
|
|
)}
|
|
|
|
{/* 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>
|
|
);
|
|
}; |