512 lines
17 KiB
TypeScript
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>
|
|
);
|
|
}
|