Files
Temp_MSSPLASHPage/NetworkMultiSelect.tsx

512 lines
17 KiB
TypeScript

/**
* NetworkMultiSelect Component
* Multi-select dropdown for network selection with badge display
* Features: Search, "All" option, condensed display, badge pills
*/
'use client';
import * as React from 'react';
import { Check, ChevronsUpDown, X, Building2, Loader2 } from 'lucide-react';
import { cn } from '@/lib/utils';
import { Button } from '@/components/ui/button';
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
CommandSeparator,
} from '@/components/ui/command';
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@/components/ui/popover';
import { Badge } from '@/components/ui/badge';
import { Separator } from '@/components/ui/separator';
import type { Network } from '../_types/dashboard.types';
export interface NetworkMultiSelectProps {
/**
* Available networks to select from
*/
networks: Network[];
/**
* Currently selected network IDs
*/
selectedNetworkIds: number[];
/**
* Callback when selection changes (called immediately for UI updates)
*/
onSelectionChange: (networkIds: number[]) => void;
/**
* Callback when popover closes with final selection
* Use this for API calls to avoid multiple requests
* @param finalSelection - The final selected network IDs when dropdown closes
*/
onPopoverClose?: (finalSelection: number[]) => void;
/**
* Placeholder text when no networks selected
*/
placeholder?: string;
/**
* Whether the select is disabled
*/
disabled?: boolean;
/**
* Whether to show the "All" option
*/
showAllOption?: boolean;
/**
* Loading state
*/
isLoading?: boolean;
/**
* Additional CSS classes
*/
className?: string;
/**
* Maximum number of badges to show before condensing
*/
maxDisplayBadges?: number;
}
const ALL_NETWORKS_ID = 0;
export function NetworkMultiSelect({
networks,
selectedNetworkIds,
onSelectionChange,
onPopoverClose,
placeholder = 'Seleccionar sucursales',
disabled = false,
showAllOption = true,
isLoading = false,
className,
maxDisplayBadges = 1,
}: NetworkMultiSelectProps) {
const [open, setOpen] = React.useState(false);
const [searchQuery, setSearchQuery] = React.useState('');
// Track selection when popover opens to detect changes on close
const selectionOnOpenRef = React.useRef<number[]>([]);
// Check if "All" is selected
const isAllSelected = selectedNetworkIds.includes(ALL_NETWORKS_ID);
// Get selected networks for display
const selectedNetworks = React.useMemo(() => {
if (isAllSelected) {
return [{ id: ALL_NETWORKS_ID, name: 'Todas las Sucursales' }];
}
return networks.filter((network) =>
selectedNetworkIds.includes(network.id)
);
}, [networks, selectedNetworkIds, isAllSelected]);
// Filter networks by search query
const filteredNetworks = React.useMemo(() => {
if (!searchQuery) return networks;
const query = searchQuery.toLowerCase();
return networks.filter(
(network) =>
network.name.toLowerCase().includes(query) ||
network.location?.toLowerCase().includes(query)
);
}, [networks, searchQuery]);
// Group networks by status for better organization
const activeNetworks = filteredNetworks.filter(
(n) => n.status === 'active'
);
const maintenanceNetworks = filteredNetworks.filter(
(n) => n.status === 'maintenance'
);
const inactiveNetworks = filteredNetworks.filter(
(n) => n.status === 'inactive'
);
// Detect when popover opens/closes and trigger callback on close
React.useEffect(() => {
if (open) {
// Popover just opened - save current selection
selectionOnOpenRef.current = [...selectedNetworkIds];
} else {
// Popover just closed - check if selection changed
if (onPopoverClose && selectionOnOpenRef.current.length > 0) {
const selectionChanged =
selectedNetworkIds.length !== selectionOnOpenRef.current.length ||
!selectedNetworkIds.every(id => selectionOnOpenRef.current.includes(id)) ||
!selectionOnOpenRef.current.every(id => selectedNetworkIds.includes(id));
if (selectionChanged) {
// Selection changed while dropdown was open - trigger API call
onPopoverClose(selectedNetworkIds);
}
}
}
}, [open, selectedNetworkIds, onPopoverClose]);
const handleSelectAll = () => {
if (isAllSelected) {
onSelectionChange([]);
} else {
onSelectionChange([ALL_NETWORKS_ID]);
}
};
const handleToggleNetwork = (networkId: number) => {
// If selecting "All", replace all selections
if (networkId === ALL_NETWORKS_ID) {
handleSelectAll();
return;
}
// If "All" is currently selected, deselect it
if (isAllSelected) {
onSelectionChange([networkId]);
return;
}
// Toggle individual network
const newSelection = selectedNetworkIds.includes(networkId)
? selectedNetworkIds.filter((id) => id !== networkId)
: [...selectedNetworkIds, networkId];
onSelectionChange(newSelection);
};
const handleRemoveNetwork = (networkId: number, e: React.MouseEvent) => {
e.stopPropagation();
if (networkId === ALL_NETWORKS_ID) {
onSelectionChange([]);
return;
}
const newSelection = selectedNetworkIds.filter((id) => id !== networkId);
onSelectionChange(newSelection);
};
const handleClearAll = (e: React.MouseEvent) => {
e.stopPropagation();
onSelectionChange([]);
};
// Format display text
const getDisplayText = () => {
if (selectedNetworks.length === 0) {
return placeholder;
}
if (isAllSelected) {
return 'Todas las Sucursales';
}
if (selectedNetworks.length === 1) {
return selectedNetworks[0].name;
}
return `${selectedNetworks.length} Sucursales`;
};
const displayBadges = selectedNetworks.slice(0, maxDisplayBadges);
const remainingCount = selectedNetworks.length - maxDisplayBadges;
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={open}
disabled={disabled || isLoading}
className={cn(
'justify-between min-w-[280px] h-auto min-h-[40px]',
selectedNetworks.length === 0 && 'text-muted-foreground',
className
)}
>
<div className="flex items-center gap-1 flex-1 flex-wrap">
<Building2 className="mr-2 h-4 w-4 shrink-0" />
{isLoading ? (
<span className="flex items-center gap-2">
<Loader2 className="h-4 w-4 animate-spin" />
Cargando...
</span>
) : selectedNetworks.length > 0 ? (
<div className="flex items-center gap-1 flex-wrap">
{displayBadges.map((network) => (
<Badge
key={network.id}
variant="secondary"
className="pl-2 pr-1 py-0.5 text-xs font-normal"
>
{network.name}
{!disabled && (
<span
role="button"
tabIndex={0}
className="ml-1 rounded-sm opacity-70 hover:opacity-100 hover:bg-muted-foreground/20 transition-opacity cursor-pointer"
onClick={(e) => handleRemoveNetwork(network.id, e)}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
handleRemoveNetwork(network.id, e as any);
}
}}
>
<X className="h-3 w-3" />
</span>
handleRemoveNetwork(network.id, e as any);
}
}}
>
<X className="h-3 w-3" />
</span>
)}
<span
role="button"
tabIndex={0}
onClick={handleClearAll}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
handleClearAll(e as any);
}
}}
className="rounded-sm opacity-70 hover:opacity-100 hover:bg-muted-foreground/20 transition-opacity cursor-pointer"
>
<X className="h-4 w-4" />
</span>
)}
</div>
) : (
<span>{placeholder}</span>
)}
</div>
<div className="flex items-center gap-1 ml-2 shrink-0">
{selectedNetworks.length > 0 && !disabled && !isLoading && (
<button
onClick={handleClearAll}
className="rounded-sm opacity-70 hover:opacity-100 hover:bg-muted-foreground/20 transition-opacity"
>
<X className="h-4 w-4" />
</button>
)}
<ChevronsUpDown className="h-4 w-4 opacity-50" />
</div>
</Button>
</PopoverTrigger>
<PopoverContent className="w-[400px] p-0" align="start">
<Command shouldFilter={false}>
<CommandInput
placeholder="Buscar sucursal..."
value={searchQuery}
onValueChange={setSearchQuery}
/>
<CommandList>
<CommandEmpty>No se encontraron sucursales.</CommandEmpty>
{/* All Networks Option */}
{showAllOption && !searchQuery && (
<>
<CommandGroup>
<CommandItem
value="all-networks"
onSelect={handleSelectAll}
className="cursor-pointer"
>
<div
className={cn(
'mr-2 flex h-4 w-4 items-center justify-center rounded-sm border border-primary',
isAllSelected
? 'bg-primary text-primary-foreground'
: 'opacity-50'
)}
>
{isAllSelected && <Check className="h-4 w-4" />}
</div>
<span className="font-medium">Todas las Sucursales</span>
<span className="ml-auto text-xs text-muted-foreground">
{networks.length}
</span>
</CommandItem>
</CommandGroup>
<CommandSeparator />
</>
)}
{/* Active Networks */}
{activeNetworks.length > 0 && (
<CommandGroup heading="Activas">
{activeNetworks.map((network) => {
const isSelected = selectedNetworkIds.includes(network.id);
return (
<CommandItem
key={network.id}
value={`${network.id}-${network.name}`}
onSelect={() => handleToggleNetwork(network.id)}
className="cursor-pointer"
>
<div
className={cn(
'mr-2 flex h-4 w-4 items-center justify-center rounded-sm border border-primary',
isSelected
? 'bg-primary text-primary-foreground'
: 'opacity-50'
)}
>
{isSelected && <Check className="h-4 w-4" />}
</div>
<div className="flex-1">
<div className="text-sm font-medium">
{network.name}
</div>
{network.location && (
<div className="text-xs text-muted-foreground">
{network.location}
</div>
)}
</div>
<div className="ml-2 flex items-center gap-1">
<span className="h-2 w-2 rounded-full bg-green-500" />
</div>
</CommandItem>
);
})}
</CommandGroup>
)}
{/* Maintenance Networks */}
{maintenanceNetworks.length > 0 && (
<>
<CommandSeparator />
<CommandGroup heading="En Mantenimiento">
{maintenanceNetworks.map((network) => {
const isSelected = selectedNetworkIds.includes(network.id);
return (
<CommandItem
key={network.id}
value={`${network.id}-${network.name}`}
onSelect={() => handleToggleNetwork(network.id)}
className="cursor-pointer opacity-75"
>
<div
className={cn(
'mr-2 flex h-4 w-4 items-center justify-center rounded-sm border border-primary',
isSelected
? 'bg-primary text-primary-foreground'
: 'opacity-50'
)}
>
{isSelected && <Check className="h-4 w-4" />}
</div>
<div className="flex-1">
<div className="text-sm font-medium">
{network.name}
</div>
{network.location && (
<div className="text-xs text-muted-foreground">
{network.location}
</div>
)}
</div>
<div className="ml-2 flex items-center gap-1">
<span className="h-2 w-2 rounded-full bg-yellow-500" />
</div>
</CommandItem>
);
})}
</CommandGroup>
</>
)}
{/* Inactive Networks */}
{inactiveNetworks.length > 0 && (
<>
<CommandSeparator />
<CommandGroup heading="Inactivas">
{inactiveNetworks.map((network) => {
const isSelected = selectedNetworkIds.includes(network.id);
return (
<CommandItem
key={network.id}
value={`${network.id}-${network.name}`}
onSelect={() => handleToggleNetwork(network.id)}
className="cursor-pointer opacity-50"
>
<div
className={cn(
'mr-2 flex h-4 w-4 items-center justify-center rounded-sm border border-primary',
isSelected
? 'bg-primary text-primary-foreground'
: 'opacity-50'
)}
>
{isSelected && <Check className="h-4 w-4" />}
</div>
<div className="flex-1">
<div className="text-sm font-medium">
{network.name}
</div>
{network.location && (
<div className="text-xs text-muted-foreground">
{network.location}
</div>
)}
</div>
<div className="ml-2 flex items-center gap-1">
<span className="h-2 w-2 rounded-full bg-gray-400" />
</div>
</CommandItem>
);
})}
</CommandGroup>
</>
)}
</CommandList>
{/* Footer */}
{selectedNetworks.length > 0 && (
<>
<Separator />
<div className="flex items-center justify-between p-2 text-xs text-muted-foreground">
<span>
{selectedNetworks.length}{' '}
{selectedNetworks.length === 1 ? 'sucursal' : 'sucursales'}{' '}
{selectedNetworks.length === 1
? 'seleccionada'
: 'seleccionadas'}
</span>
{!disabled && (
<Button
variant="ghost"
size="sm"
onClick={handleClearAll}
className="h-6 text-xs"
>
Limpiar todo
</Button>
)}
</div>
</>
)}
</Command>
</PopoverContent>
</Popover>
);
}