From f13b7239a55fe0725ede4d1a34595179e073dcf8 Mon Sep 17 00:00:00 2001 From: Fringg Date: Fri, 24 Apr 2026 06:20:43 +0300 Subject: [PATCH] feat: multi-select tariff filter, select-all subscriptions, auto-expand MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. Tariff filter: replace single-select dropdown with MultiSelectDropdown component — checkboxes for each tariff, "Select all" / "Deselect all" controls, shows selected names or count in button label 2. Client-side subscription filtering: when tariffs selected in filter, only show matching subscriptions in expanded sub-rows 3. "Select all subscriptions" button in FloatingActionBar — toggles all visible subscription checkboxes and auto-expands relevant rows 4. Auto-expand: all user rows with multiple subscriptions are expanded by default when data loads (no need to click chevron) 5. i18n: new keys for tariff multi-select and subscription select-all --- src/locales/en.json | 9 +- src/locales/ru.json | 9 +- src/pages/AdminBulkActions.tsx | 278 +++++++++++++++++++++++++++++++-- 3 files changed, 276 insertions(+), 20 deletions(-) diff --git a/src/locales/en.json b/src/locales/en.json index 4a4a6cc..94f67b4 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -2078,7 +2078,10 @@ "promoGroup": "Promo group", "allStatuses": "All statuses", "allTariffs": "All tariffs", - "allGroups": "All groups" + "allGroups": "All groups", + "tariffsSelected": "{{count}} tariffs", + "selectAll": "Select all", + "deselectAll": "Deselect all" }, "statuses": { "active": "Active", @@ -2105,7 +2108,9 @@ "userTarget": "Target: users", "daysUnit": "d", "trafficOf": "of", - "trafficGbUnit": "GB" + "trafficGbUnit": "GB", + "selectAllSubs": "Select all subscriptions", + "deselectAllSubs": "Deselect all subscriptions" }, "theme": { "accentColor": "Accent color", diff --git a/src/locales/ru.json b/src/locales/ru.json index c971e1f..791275f 100644 --- a/src/locales/ru.json +++ b/src/locales/ru.json @@ -3826,7 +3826,10 @@ "promoGroup": "Промогруппа", "allStatuses": "Все статусы", "allTariffs": "Все тарифы", - "allGroups": "Все группы" + "allGroups": "Все группы", + "tariffsSelected": "{{count}} тарифов", + "selectAll": "Выбрать все", + "deselectAll": "Снять все" }, "statuses": { "active": "Активна", @@ -3853,7 +3856,9 @@ "userTarget": "Цель: пользователи", "daysUnit": "дн.", "trafficOf": "из", - "trafficGbUnit": "ГБ" + "trafficGbUnit": "ГБ", + "selectAllSubs": "Выбрать все подписки", + "deselectAllSubs": "Снять все подписки" }, "theme": { "accentColor": "Акцентный цвет", diff --git a/src/pages/AdminBulkActions.tsx b/src/pages/AdminBulkActions.tsx index 12ff25a..9efbd3f 100644 --- a/src/pages/AdminBulkActions.tsx +++ b/src/pages/AdminBulkActions.tsx @@ -396,6 +396,162 @@ function DropdownSelect({ ); } +// ============ Multi-Select Dropdown ============ + +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(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 ( +
+ + + {open && ( +
+ {/* Select all / Deselect all */} +
+ + / + +
+ + {/* Options */} + {options.map((option) => { + const isChecked = selected.includes(option.value); + return ( + + ); + })} +
+ )} +
+ ); +} + // ============ Progress View ============ function ProgressView({ progress }: { progress: ProgressState }) { @@ -912,14 +1068,18 @@ interface FloatingActionBarProps { selectedUserCount: number; selectedSubscriptionCount: number; isMultiTariff: boolean; + totalVisibleSubscriptionCount: number; onAction: (type: BulkActionType) => void; + onToggleAllSubscriptions: () => void; } function FloatingActionBar({ selectedUserCount, selectedSubscriptionCount, isMultiTariff, + totalVisibleSubscriptionCount, onAction, + onToggleAllSubscriptions, }: FloatingActionBarProps) { const { t } = useTranslation(); const [open, setOpen] = useState(false); @@ -1022,6 +1182,22 @@ function FloatingActionBar({ )} + {/* Toggle all subscriptions button */} + {isMultiTariff && totalVisibleSubscriptionCount > 0 && ( + <> +
+ + + )} +
{/* Actions dropdown */} @@ -1133,7 +1309,7 @@ export default function AdminBulkActions() { const [searchInput, setSearchInput] = useState(''); const [committedSearch, setCommittedSearch] = useState(''); const [statusFilter, setStatusFilter] = useState(''); - const [tariffFilter, setTariffFilter] = useState(''); + const [tariffFilter, setTariffFilter] = useState([]); const [promoGroupFilter, setPromoGroupFilter] = useState(''); // Pagination @@ -1174,7 +1350,7 @@ export default function AdminBulkActions() { }; if (committedSearch) params.search = committedSearch; if (statusFilter) params.subscription_status = statusFilter; - if (tariffFilter) params.tariff_id = Number(tariffFilter); + if (tariffFilter.length === 1) params.tariff_id = tariffFilter[0]; if (promoGroupFilter) params.promo_group_id = Number(promoGroupFilter); const data = await adminUsersApi.getUsers( @@ -1182,6 +1358,14 @@ export default function AdminBulkActions() { ); setUsers(data.users); setTotal(data.total); + // Auto-expand all users with multiple subscriptions + const autoExpand: Record = {}; + for (const u of data.users) { + if ((u.subscriptions?.length ?? 0) > 1) { + autoExpand[u.id] = true; + } + } + setExpandedRows(autoExpand); } catch { // keep stale data } finally { @@ -1247,8 +1431,8 @@ export default function AdminBulkActions() { clearAllSelections(); }; - const handleTariffFilterChange = (v: string) => { - setTariffFilter(v); + const handleTariffFilterChange = (ids: number[]) => { + setTariffFilter(ids); setOffset(0); clearAllSelections(); }; @@ -1291,6 +1475,64 @@ export default function AdminBulkActions() { })); }, []); + const getFilteredSubs = useCallback( + (subs: UserListItemSubscription[]): UserListItemSubscription[] => { + if (tariffFilter.length === 0) return subs; + return subs.filter((s) => s.tariff_id !== null && tariffFilter.includes(s.tariff_id)); + }, + [tariffFilter], + ); + + const allVisibleSubscriptionIds = useMemo(() => { + const ids: number[] = []; + for (const user of users) { + const subs = user.subscriptions ?? []; + const filtered = getFilteredSubs(subs); + if (filtered.length > 1) { + for (const sub of filtered) { + ids.push(sub.id); + } + } + } + return ids; + }, [users, getFilteredSubs]); + + const toggleAllSubscriptions = useCallback(() => { + const allSelected = + allVisibleSubscriptionIds.length > 0 && + allVisibleSubscriptionIds.every((id) => subscriptionSelection[id]); + + if (allSelected) { + // Deselect all + const next: Record = {}; + for (const key of Object.keys(subscriptionSelection)) { + const id = Number(key); + if (!allVisibleSubscriptionIds.includes(id)) { + next[id] = subscriptionSelection[id]; + } + } + setSubscriptionSelection(next); + } else { + // Select all visible + const next = { ...subscriptionSelection }; + for (const id of allVisibleSubscriptionIds) { + next[id] = true; + } + setSubscriptionSelection(next); + } + + // Auto-expand rows that have filtered subs + const expanded: Record = { ...expandedRows }; + for (const user of users) { + const subs = user.subscriptions ?? []; + const filtered = getFilteredSubs(subs); + if (filtered.length > 1) { + expanded[user.id] = true; + } + } + setExpandedRows(expanded); + }, [allVisibleSubscriptionIds, subscriptionSelection, expandedRows, users, getFilteredSubs]); + const handleOpenAction = (type: BulkActionType) => { setModal({ open: true, action: type, loading: false, result: null, progress: null }); }; @@ -1475,7 +1717,8 @@ export default function AdminBulkActions() { size: 200, cell: ({ row }) => { const user = row.original; - const subCount = user.subscriptions?.length ?? 0; + const filteredSubs = getFilteredSubs(user.subscriptions ?? []); + const subCount = filteredSubs.length; const canExpand = subCount > 1; const isExpanded = expandedRows[user.id] ?? false; return ( @@ -1615,7 +1858,7 @@ export default function AdminBulkActions() { }, }, ], - [t, formatWithCurrency, expandedRows, toggleExpandRow], + [t, formatWithCurrency, expandedRows, toggleExpandRow, getFilteredSubs], ); const table = useReactTable({ @@ -1641,10 +1884,10 @@ export default function AdminBulkActions() { { value: 'disabled', label: t('admin.bulkActions.statuses.disabled') }, ]; - const tariffOptions: DropdownOption[] = [ - { value: '', label: t('admin.bulkActions.filters.allTariffs') }, - ...tariffs.map((tt) => ({ value: String(tt.id), label: tt.name })), - ]; + const tariffMultiOptions: MultiSelectOption[] = tariffs.map((tt) => ({ + value: tt.id, + label: tt.name, + })); const promoGroupOptions: DropdownOption[] = [ { value: '', label: t('admin.bulkActions.filters.allGroups') }, @@ -1712,10 +1955,11 @@ export default function AdminBulkActions() { options={statusOptions} onChange={handleStatusFilterChange} /> - {table.getRowModel().rows.map((row) => { const user = row.original; - const subs = user.subscriptions ?? []; - const canExpand = subs.length > 1; + const filteredSubs = getFilteredSubs(user.subscriptions ?? []); + const canExpand = filteredSubs.length > 1; const isExpanded = expandedRows[user.id] ?? false; return ( @@ -1794,7 +2038,7 @@ export default function AdminBulkActions() { {/* Subscription sub-rows (only when expanded and multi-sub) */} {canExpand && isExpanded && - subs.map((sub) => ( + filteredSubs.map((sub) => ( {/* Action modal */}