mirror of
https://github.com/chillpadclub/bedolaga-cabinet.git
synced 2026-07-29 18:13:47 +00:00
refactor(bulk-actions): extract MultiSelectDropdown
Third step in the AdminBulkActions decomp. Move the 154-line pop-over multi-select used by the tariff / status / node filters into src/components/admin/bulkActions/MultiSelectDropdown.tsx. Pure controlled primitive: closes on outside click, provides select-all / deselect-all helpers, uses the shared ChevronDownIcon from DropdownSelect.tsx. Exports MultiSelectOption type alongside. Parent drops its inline ChevronDownIcon import (now only referenced by the moved sibling) and imports MultiSelectDropdown + the option type from the new module. AdminBulkActions.tsx: 1517 → 1367 lines (-150).
This commit is contained in:
164
src/components/admin/bulkActions/MultiSelectDropdown.tsx
Normal file
164
src/components/admin/bulkActions/MultiSelectDropdown.tsx
Normal file
@@ -0,0 +1,164 @@
|
|||||||
|
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import { ChevronDownIcon } from './DropdownSelect';
|
||||||
|
|
||||||
|
// ──────────────────────────────────────────────────────────────────
|
||||||
|
// MultiSelectDropdown
|
||||||
|
//
|
||||||
|
// Pop-over multi-select used by AdminBulkActions filters (tariffs,
|
||||||
|
// statuses, nodes, etc.). Closes on outside click; provides
|
||||||
|
// select-all / deselect-all helpers. Pure controlled component.
|
||||||
|
// ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface MultiSelectOption {
|
||||||
|
value: number;
|
||||||
|
label: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MultiSelectDropdownProps {
|
||||||
|
options: MultiSelectOption[];
|
||||||
|
selected: number[];
|
||||||
|
onChange: (ids: number[]) => void;
|
||||||
|
placeholder: string;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MultiSelectDropdown({
|
||||||
|
options,
|
||||||
|
selected,
|
||||||
|
onChange,
|
||||||
|
placeholder,
|
||||||
|
className,
|
||||||
|
}: MultiSelectDropdownProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
const handler = (e: MouseEvent) => {
|
||||||
|
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
|
||||||
|
setOpen(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
document.addEventListener('mousedown', handler);
|
||||||
|
return () => document.removeEventListener('mousedown', handler);
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
const buttonLabel = useMemo(() => {
|
||||||
|
if (selected.length === 0) return placeholder;
|
||||||
|
if (selected.length <= 2) {
|
||||||
|
return selected
|
||||||
|
.map((id) => options.find((o) => o.value === id)?.label)
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(', ');
|
||||||
|
}
|
||||||
|
return t('admin.bulkActions.filters.tariffsSelected', { count: selected.length });
|
||||||
|
}, [selected, options, placeholder, t]);
|
||||||
|
|
||||||
|
const handleToggle = (value: number) => {
|
||||||
|
if (selected.includes(value)) {
|
||||||
|
onChange(selected.filter((id) => id !== value));
|
||||||
|
} else {
|
||||||
|
onChange([...selected, value]);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSelectAll = () => {
|
||||||
|
onChange(options.map((o) => o.value));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeselectAll = () => {
|
||||||
|
onChange([]);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={containerRef} className={cn('relative', className)}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setOpen(!open)}
|
||||||
|
className={cn(
|
||||||
|
'flex w-full items-center justify-between rounded-xl border bg-dark-800 px-3 py-2.5 text-left text-sm outline-none transition-colors',
|
||||||
|
open
|
||||||
|
? 'border-accent-500/40 shadow-[0_0_0_3px_rgba(var(--color-accent-500),0.08)]'
|
||||||
|
: 'border-dark-700',
|
||||||
|
selected.length > 0 ? 'text-dark-100' : 'text-dark-500',
|
||||||
|
)}
|
||||||
|
aria-expanded={open}
|
||||||
|
aria-haspopup="listbox"
|
||||||
|
>
|
||||||
|
<span className="truncate">{buttonLabel}</span>
|
||||||
|
<div
|
||||||
|
className={cn('ml-2 shrink-0 text-dark-500 transition-transform', open && 'rotate-180')}
|
||||||
|
>
|
||||||
|
<ChevronDownIcon />
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{open && (
|
||||||
|
<div className="absolute left-0 right-0 top-full z-50 mt-1 max-h-64 overflow-y-auto rounded-xl border border-dark-700 bg-dark-800 py-1 shadow-2xl">
|
||||||
|
<div className="flex items-center gap-1 border-b border-dark-700/50 px-3 py-1.5">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleSelectAll}
|
||||||
|
className="text-xs font-medium text-accent-400 transition-colors hover:text-accent-300"
|
||||||
|
>
|
||||||
|
{t('admin.bulkActions.filters.selectAll')}
|
||||||
|
</button>
|
||||||
|
<span className="text-dark-600">/</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleDeselectAll}
|
||||||
|
className="text-xs font-medium text-dark-400 transition-colors hover:text-dark-300"
|
||||||
|
>
|
||||||
|
{t('admin.bulkActions.filters.deselectAll')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{options.map((option) => {
|
||||||
|
const isChecked = selected.includes(option.value);
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={option.value}
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleToggle(option.value)}
|
||||||
|
className="flex w-full items-center gap-2.5 px-3 py-2 text-left text-sm transition-colors hover:bg-dark-700/50"
|
||||||
|
role="option"
|
||||||
|
aria-selected={isChecked}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'flex h-4 w-4 shrink-0 items-center justify-center rounded border transition-all duration-150',
|
||||||
|
isChecked
|
||||||
|
? 'border-accent-500 bg-accent-500'
|
||||||
|
: 'border-dark-500 bg-dark-700/60',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{isChecked && (
|
||||||
|
<svg
|
||||||
|
className="h-2.5 w-2.5 text-white"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth={4}
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
d="M4.5 12.75l6 6 9-13.5"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<span className={cn('text-sm', isChecked ? 'text-dark-100' : 'text-dark-300')}>
|
||||||
|
{option.label}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -31,6 +31,10 @@ import {
|
|||||||
} from '@/components/admin/bulkActions/DropdownSelect';
|
} from '@/components/admin/bulkActions/DropdownSelect';
|
||||||
import { FloatingActionBar } from '@/components/admin/bulkActions/FloatingActionBar';
|
import { FloatingActionBar } from '@/components/admin/bulkActions/FloatingActionBar';
|
||||||
import { isSubscriptionLevelAction } from '@/components/admin/bulkActions/actionTargets';
|
import { isSubscriptionLevelAction } from '@/components/admin/bulkActions/actionTargets';
|
||||||
|
import {
|
||||||
|
MultiSelectDropdown,
|
||||||
|
type MultiSelectOption,
|
||||||
|
} from '@/components/admin/bulkActions/MultiSelectDropdown';
|
||||||
|
|
||||||
// ============ Types ============
|
// ============ Types ============
|
||||||
|
|
||||||
@@ -351,161 +355,7 @@ function ProgressBar({ loading }: { loading: boolean }) {
|
|||||||
|
|
||||||
// (DropdownSelect moved into ./bulkActions/DropdownSelect.tsx)
|
// (DropdownSelect moved into ./bulkActions/DropdownSelect.tsx)
|
||||||
|
|
||||||
// ============ Multi-Select Dropdown ============
|
// (MultiSelectDropdown moved into ./bulkActions/MultiSelectDropdown.tsx)
|
||||||
|
|
||||||
interface MultiSelectOption {
|
|
||||||
value: number;
|
|
||||||
label: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface MultiSelectDropdownProps {
|
|
||||||
options: MultiSelectOption[];
|
|
||||||
selected: number[];
|
|
||||||
onChange: (ids: number[]) => void;
|
|
||||||
placeholder: string;
|
|
||||||
className?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
function MultiSelectDropdown({
|
|
||||||
options,
|
|
||||||
selected,
|
|
||||||
onChange,
|
|
||||||
placeholder,
|
|
||||||
className,
|
|
||||||
}: MultiSelectDropdownProps) {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const [open, setOpen] = useState(false);
|
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!open) return;
|
|
||||||
const handler = (e: MouseEvent) => {
|
|
||||||
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
|
|
||||||
setOpen(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
document.addEventListener('mousedown', handler);
|
|
||||||
return () => document.removeEventListener('mousedown', handler);
|
|
||||||
}, [open]);
|
|
||||||
|
|
||||||
const buttonLabel = useMemo(() => {
|
|
||||||
if (selected.length === 0) return placeholder;
|
|
||||||
if (selected.length <= 2) {
|
|
||||||
return selected
|
|
||||||
.map((id) => options.find((o) => o.value === id)?.label)
|
|
||||||
.filter(Boolean)
|
|
||||||
.join(', ');
|
|
||||||
}
|
|
||||||
return t('admin.bulkActions.filters.tariffsSelected', { count: selected.length });
|
|
||||||
}, [selected, options, placeholder, t]);
|
|
||||||
|
|
||||||
const handleToggle = (value: number) => {
|
|
||||||
if (selected.includes(value)) {
|
|
||||||
onChange(selected.filter((id) => id !== value));
|
|
||||||
} else {
|
|
||||||
onChange([...selected, value]);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSelectAll = () => {
|
|
||||||
onChange(options.map((o) => o.value));
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDeselectAll = () => {
|
|
||||||
onChange([]);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div ref={containerRef} className={cn('relative', className)}>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setOpen(!open)}
|
|
||||||
className={cn(
|
|
||||||
'flex w-full items-center justify-between rounded-xl border bg-dark-800 px-3 py-2.5 text-left text-sm outline-none transition-colors',
|
|
||||||
open
|
|
||||||
? 'border-accent-500/40 shadow-[0_0_0_3px_rgba(var(--color-accent-500),0.08)]'
|
|
||||||
: 'border-dark-700',
|
|
||||||
selected.length > 0 ? 'text-dark-100' : 'text-dark-500',
|
|
||||||
)}
|
|
||||||
aria-expanded={open}
|
|
||||||
aria-haspopup="listbox"
|
|
||||||
>
|
|
||||||
<span className="truncate">{buttonLabel}</span>
|
|
||||||
<div
|
|
||||||
className={cn('ml-2 shrink-0 text-dark-500 transition-transform', open && 'rotate-180')}
|
|
||||||
>
|
|
||||||
<ChevronDownIcon />
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{open && (
|
|
||||||
<div className="absolute left-0 right-0 top-full z-50 mt-1 max-h-64 overflow-y-auto rounded-xl border border-dark-700 bg-dark-800 py-1 shadow-2xl">
|
|
||||||
{/* Select all / Deselect all */}
|
|
||||||
<div className="flex items-center gap-1 border-b border-dark-700/50 px-3 py-1.5">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={handleSelectAll}
|
|
||||||
className="text-xs font-medium text-accent-400 transition-colors hover:text-accent-300"
|
|
||||||
>
|
|
||||||
{t('admin.bulkActions.filters.selectAll')}
|
|
||||||
</button>
|
|
||||||
<span className="text-dark-600">/</span>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={handleDeselectAll}
|
|
||||||
className="text-xs font-medium text-dark-400 transition-colors hover:text-dark-300"
|
|
||||||
>
|
|
||||||
{t('admin.bulkActions.filters.deselectAll')}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Options */}
|
|
||||||
{options.map((option) => {
|
|
||||||
const isChecked = selected.includes(option.value);
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
key={option.value}
|
|
||||||
type="button"
|
|
||||||
onClick={() => handleToggle(option.value)}
|
|
||||||
className="flex w-full items-center gap-2.5 px-3 py-2 text-left text-sm transition-colors hover:bg-dark-700/50"
|
|
||||||
role="option"
|
|
||||||
aria-selected={isChecked}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
'flex h-4 w-4 shrink-0 items-center justify-center rounded border transition-all duration-150',
|
|
||||||
isChecked
|
|
||||||
? 'border-accent-500 bg-accent-500'
|
|
||||||
: 'border-dark-500 bg-dark-700/60',
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{isChecked && (
|
|
||||||
<svg
|
|
||||||
className="h-2.5 w-2.5 text-white"
|
|
||||||
fill="none"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
stroke="currentColor"
|
|
||||||
strokeWidth={4}
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
d="M4.5 12.75l6 6 9-13.5"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<span className={cn('text-sm', isChecked ? 'text-dark-100' : 'text-dark-300')}>
|
|
||||||
{option.label}
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// (ProgressView moved into <ActionModal>)
|
// (ProgressView moved into <ActionModal>)
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user