215 lines
7.2 KiB
TypeScript
215 lines
7.2 KiB
TypeScript
|
|
import React, { useState, useRef, useEffect } from 'react';
|
||
|
|
import { useTenant } from '../../stores/tenant.store';
|
||
|
|
import { useToast } from '../../hooks/ui/useToast';
|
||
|
|
import { ChevronDown, Building2, Check, AlertCircle } from 'lucide-react';
|
||
|
|
|
||
|
|
interface TenantSwitcherProps {
|
||
|
|
className?: string;
|
||
|
|
showLabel?: boolean;
|
||
|
|
}
|
||
|
|
|
||
|
|
export const TenantSwitcher: React.FC<TenantSwitcherProps> = ({
|
||
|
|
className = '',
|
||
|
|
showLabel = true,
|
||
|
|
}) => {
|
||
|
|
const [isOpen, setIsOpen] = useState(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) => {
|
||
|
|
if (
|
||
|
|
dropdownRef.current &&
|
||
|
|
!dropdownRef.current.contains(event.target as Node) &&
|
||
|
|
!buttonRef.current?.contains(event.target as Node)
|
||
|
|
) {
|
||
|
|
setIsOpen(false);
|
||
|
|
}
|
||
|
|
};
|
||
|
|
|
||
|
|
if (isOpen) {
|
||
|
|
document.addEventListener('mousedown', handleClickOutside);
|
||
|
|
}
|
||
|
|
|
||
|
|
return () => {
|
||
|
|
document.removeEventListener('mousedown', handleClickOutside);
|
||
|
|
};
|
||
|
|
}, [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();
|
||
|
|
};
|
||
|
|
|
||
|
|
// 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={() => setIsOpen(!isOpen)}
|
||
|
|
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 */}
|
||
|
|
{isOpen && (
|
||
|
|
<div
|
||
|
|
ref={dropdownRef}
|
||
|
|
className="absolute right-0 mt-2 w-72 bg-bg-primary border border-border-secondary rounded-lg shadow-lg z-50"
|
||
|
|
role="listbox"
|
||
|
|
aria-label="Available tenants"
|
||
|
|
>
|
||
|
|
{/* Header */}
|
||
|
|
<div className="px-3 py-2 border-b border-border-primary">
|
||
|
|
<h3 className="text-sm font-semibold text-text-primary">Switch Organization</h3>
|
||
|
|
<p className="text-xs text-text-secondary">
|
||
|
|
Select the organization you want to work with
|
||
|
|
</p>
|
||
|
|
</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="max-h-80 overflow-y-auto">
|
||
|
|
{availableTenants.map((tenant) => (
|
||
|
|
<button
|
||
|
|
key={tenant.id}
|
||
|
|
onClick={() => handleTenantSwitch(tenant.id)}
|
||
|
|
disabled={isLoading}
|
||
|
|
className="w-full px-3 py-3 text-left hover:bg-bg-secondary focus:bg-bg-secondary focus:outline-none disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||
|
|
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-2">
|
||
|
|
<div className="w-8 h-8 bg-color-primary/10 rounded-full flex items-center justify-center flex-shrink-0">
|
||
|
|
<Building2 className="w-4 h-4 text-color-primary" />
|
||
|
|
</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.business_type} • {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="px-3 py-2 border-t border-border-primary bg-bg-secondary rounded-b-lg">
|
||
|
|
<p className="text-xs text-text-secondary">
|
||
|
|
Need to add a new organization?{' '}
|
||
|
|
<button className="text-color-primary hover:text-color-primary-dark underline">
|
||
|
|
Contact Support
|
||
|
|
</button>
|
||
|
|
</p>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
)}
|
||
|
|
|
||
|
|
{/* 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>
|
||
|
|
);
|
||
|
|
};
|