From db76cd0c647fb7be986bdebad4cf7f56b554334c Mon Sep 17 00:00:00 2001 From: Fringg Date: Fri, 20 Mar 2026 02:31:11 +0300 Subject: [PATCH] feat: multi-select scope for referral network graph Replace single-select scope with multi-select supporting multiple campaigns, partners, and users simultaneously. Chips UI with remove buttons, clear-all, sorted query keys for cache stability. --- src/api/referralNetwork.ts | 16 +- src/locales/en.json | 5 +- src/locales/fa.json | 5 +- src/locales/ru.json | 5 +- src/locales/zh.json | 5 +- src/pages/ReferralNetwork/ReferralNetwork.tsx | 30 +- .../components/ScopeSelector.tsx | 449 +++++++++++------- src/store/referralNetwork.ts | 39 +- 8 files changed, 364 insertions(+), 190 deletions(-) diff --git a/src/api/referralNetwork.ts b/src/api/referralNetwork.ts index 5d63908..9751d08 100644 --- a/src/api/referralNetwork.ts +++ b/src/api/referralNetwork.ts @@ -5,6 +5,7 @@ import type { NetworkCampaignDetail, NetworkSearchResult, ScopeOptionsData, + ScopeSelection, } from '@/types/referralNetwork'; export const referralNetworkApi = { @@ -13,9 +14,20 @@ export const referralNetworkApi = { return response.data; }, - getScopedGraph: async (scope: string, id: number): Promise => { + getScopedGraph: async (selections: ScopeSelection[]): Promise => { + const campaignIds = selections.filter((s) => s.type === 'campaign').map((s) => s.id); + const partnerIds = selections.filter((s) => s.type === 'partner').map((s) => s.id); + const userIds = selections.filter((s) => s.type === 'user').map((s) => s.id); + const response = await apiClient.get('/cabinet/admin/referral-network/scoped', { - params: { scope, id }, + params: { + campaign_ids: campaignIds, + partner_ids: partnerIds, + user_ids: userIds, + }, + paramsSerializer: { + indexes: null, + }, }); return response.data; }, diff --git a/src/locales/en.json b/src/locales/en.json index 68551ab..80d79fa 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -1306,7 +1306,10 @@ "campaigns": "campaigns", "active": "Active", "inactive": "Inactive", - "noResults": "Nothing found" + "noResults": "Nothing found", + "removeItem": "Remove {{label}}", + "clearAll": "Clear all selections", + "addScope": "Add scope" }, "loading": "Loading graph...", "error": "Failed to load network data", diff --git a/src/locales/fa.json b/src/locales/fa.json index 067c200..47f419e 100644 --- a/src/locales/fa.json +++ b/src/locales/fa.json @@ -1140,7 +1140,10 @@ "campaigns": "کمپین‌ها", "active": "فعال", "inactive": "غیرفعال", - "noResults": "نتیجه‌ای یافت نشد" + "noResults": "نتیجه‌ای یافت نشد", + "removeItem": "حذف {{label}}", + "clearAll": "پاک کردن همه انتخاب‌ها", + "addScope": "افزودن محدوده" }, "loading": "بارگذاری گراف...", "error": "خطا در بارگذاری داده‌های شبکه", diff --git a/src/locales/ru.json b/src/locales/ru.json index 5e50c7c..11dbf8f 100644 --- a/src/locales/ru.json +++ b/src/locales/ru.json @@ -1327,7 +1327,10 @@ "campaigns": "кампаний", "active": "Активна", "inactive": "Неактивна", - "noResults": "Ничего не найдено" + "noResults": "Ничего не найдено", + "removeItem": "Удалить {{label}}", + "clearAll": "Очистить все выбранные", + "addScope": "Добавить область" }, "loading": "Загрузка графа...", "error": "Ошибка загрузки данных сети", diff --git a/src/locales/zh.json b/src/locales/zh.json index c8fa8a7..e7543c6 100644 --- a/src/locales/zh.json +++ b/src/locales/zh.json @@ -1140,7 +1140,10 @@ "campaigns": "活动", "active": "活跃", "inactive": "非活跃", - "noResults": "未找到" + "noResults": "未找到", + "removeItem": "移除 {{label}}", + "clearAll": "清除所有选择", + "addScope": "添加范围" }, "loading": "加载图表...", "error": "加载网络数据失败", diff --git a/src/pages/ReferralNetwork/ReferralNetwork.tsx b/src/pages/ReferralNetwork/ReferralNetwork.tsx index 936952a..4e08d27 100644 --- a/src/pages/ReferralNetwork/ReferralNetwork.tsx +++ b/src/pages/ReferralNetwork/ReferralNetwork.tsx @@ -16,16 +16,20 @@ export function ReferralNetwork() { const { t } = useTranslation(); const selectedNode = useReferralNetworkStore((s) => s.selectedNode); const scope = useReferralNetworkStore((s) => s.scope); - const setScope = useReferralNetworkStore((s) => s.setScope); + const addScope = useReferralNetworkStore((s) => s.addScope); + const removeScope = useReferralNetworkStore((s) => s.removeScope); + const clearScope = useReferralNetworkStore((s) => s.clearScope); + + const hasScope = scope.length > 0; const { data: networkData, isLoading, isError, } = useQuery({ - queryKey: ['referral-network', 'scoped', scope?.type, scope?.id], - queryFn: () => referralNetworkApi.getScopedGraph(scope!.type, scope!.id), - enabled: scope !== null, + queryKey: ['referral-network', 'scoped', scope.map((s) => `${s.type}:${s.id}`).sort()], + queryFn: () => referralNetworkApi.getScopedGraph(scope), + enabled: hasScope, staleTime: 120_000, }); @@ -42,13 +46,19 @@ export function ReferralNetwork() {

{t('admin.referralNetwork.title')}

- +
{/* Empty state: no scope selected */} - {!scope && ( + {!hasScope && (
@@ -82,14 +92,14 @@ export function ReferralNetwork() { )} {/* Error state */} - {scope && isError && ( + {hasScope && isError && (

{t('admin.referralNetwork.error')}

)} {/* Empty data */} - {scope && + {hasScope && networkData && networkData.users.length === 0 && networkData.campaigns.length === 0 && ( @@ -99,7 +109,7 @@ export function ReferralNetwork() { )} {/* Graph + overlays */} - {scope && + {hasScope && networkData && (networkData.users.length > 0 || networkData.campaigns.length > 0) && ( <> diff --git a/src/pages/ReferralNetwork/components/ScopeSelector.tsx b/src/pages/ReferralNetwork/components/ScopeSelector.tsx index b1fe24e..02c00a8 100644 --- a/src/pages/ReferralNetwork/components/ScopeSelector.tsx +++ b/src/pages/ReferralNetwork/components/ScopeSelector.tsx @@ -5,16 +5,24 @@ import { referralNetworkApi } from '@/api/referralNetwork'; import type { ScopeSelection, ScopeType } from '@/types/referralNetwork'; interface ScopeSelectorProps { - value: ScopeSelection | null; - onSelect: (selection: ScopeSelection | null) => void; + value: ScopeSelection[]; + onAdd: (selection: ScopeSelection) => void; + onRemove: (type: ScopeSelection['type'], id: number) => void; + onClear: () => void; className?: string; } const SCOPE_TABS: ScopeType[] = ['campaign', 'partner', 'user']; -export function ScopeSelector({ value, onSelect, className }: ScopeSelectorProps) { +const CHIP_COLORS: Record = { + campaign: 'bg-success-500/20 text-success-400', + partner: 'bg-warning-500/20 text-warning-400', + user: 'bg-accent-500/20 text-accent-400', +}; + +export function ScopeSelector({ value, onAdd, onRemove, onClear, className }: ScopeSelectorProps) { const { t } = useTranslation(); - const [activeTab, setActiveTab] = useState(value?.type ?? 'campaign'); + const [activeTab, setActiveTab] = useState('campaign'); const [searchInput, setSearchInput] = useState(''); const [debouncedUserQuery, setDebouncedUserQuery] = useState(''); const [isDropdownOpen, setIsDropdownOpen] = useState(false); @@ -43,17 +51,30 @@ export function ScopeSelector({ value, onSelect, className }: ScopeSelectorProps staleTime: 30_000, }); - // Close dropdown on outside click + // Close dropdown on outside click or Escape key useEffect(() => { function handleClickOutside(e: MouseEvent) { if (containerRef.current && !containerRef.current.contains(e.target as Node)) { setIsDropdownOpen(false); } } + function handleEscape(e: KeyboardEvent) { + if (e.key === 'Escape') { + setIsDropdownOpen(false); + } + } document.addEventListener('mousedown', handleClickOutside); - return () => document.removeEventListener('mousedown', handleClickOutside); + document.addEventListener('keydown', handleEscape); + return () => { + document.removeEventListener('mousedown', handleClickOutside); + document.removeEventListener('keydown', handleEscape); + }; }, []); + // Check if item is already selected + const isSelected = (type: ScopeType, id: number): boolean => + value.some((s) => s.type === type && s.id === id); + // Filter campaigns/partners by local search input const filteredCampaigns = useMemo(() => { if (!scopeOptions) return []; @@ -84,28 +105,29 @@ export function ScopeSelector({ value, onSelect, className }: ScopeSelectorProps } function handleSelectCampaign(campaign: { id: number; name: string }) { - onSelect({ type: 'campaign', id: campaign.id, label: campaign.name }); - setIsDropdownOpen(false); - setSearchInput(''); + if (isSelected('campaign', campaign.id)) { + onRemove('campaign', campaign.id); + } else { + onAdd({ type: 'campaign', id: campaign.id, label: campaign.name }); + } } function handleSelectPartner(partner: { id: number; display_name: string }) { - onSelect({ type: 'partner', id: partner.id, label: partner.display_name }); - setIsDropdownOpen(false); - setSearchInput(''); + if (isSelected('partner', partner.id)) { + onRemove('partner', partner.id); + } else { + onAdd({ type: 'partner', id: partner.id, label: partner.display_name }); + } } function handleSelectUser(user: { id: number; display_name: string }) { - onSelect({ type: 'user', id: user.id, label: user.display_name }); - setIsDropdownOpen(false); - setSearchInput(''); - } - - function handleClear() { - onSelect(null); + if (isSelected('user', user.id)) { + onRemove('user', user.id); + } else { + onAdd({ type: 'user', id: user.id, label: user.display_name }); + } setSearchInput(''); setDebouncedUserQuery(''); - setIsDropdownOpen(false); } const tabLabels: Record = { @@ -120,18 +142,69 @@ export function ScopeSelector({ value, onSelect, className }: ScopeSelectorProps user: t('admin.referralNetwork.scope.selectUser'), }; - // When scope is selected, show compact display - if (value) { - return ( -
- - {tabLabels[value.type]} - - {value.label} + const isLoading = activeTab === 'user' ? isUserSearching : isScopeLoading; + + return ( +
+ {/* Chips row + search trigger */} +
+ {/* Selected chips */} + {value.length > 0 && ( +
+ {value.map((item) => ( + + {item.label} + + + ))} + {/* Clear all */} + +
+ )} + + {/* Add button (always visible) */}
- ); - } - - const isLoading = activeTab === 'user' ? isUserSearching : isScopeLoading; - - return ( -
- {/* Tab bar + search input row */} -
- {/* Tabs */} -
- {SCOPE_TABS.map((tab) => ( - - ))} -
- - {/* Search input */} -
- - - - { - setSearchInput(e.target.value); - if (!isDropdownOpen) setIsDropdownOpen(true); - }} - onFocus={() => setIsDropdownOpen(true)} - placeholder={placeholders[activeTab]} - className="w-full rounded-lg border border-dark-700/50 bg-dark-800/50 py-1.5 pl-8 pr-8 text-sm text-dark-100 placeholder-dark-500 outline-none transition-colors focus:border-accent-500/50 focus:ring-1 focus:ring-accent-500/30" - /> - {isLoading && ( -
-
-
- )} -
-
{/* Dropdown */} {isDropdownOpen && ( -
- {activeTab === 'campaign' && renderCampaignList()} - {activeTab === 'partner' && renderPartnerList()} - {activeTab === 'user' && renderUserList()} +
+ {/* Tab bar + search input */} +
+
+ {SCOPE_TABS.map((tab) => ( + + ))} +
+ +
+ + + + setSearchInput(e.target.value)} + placeholder={placeholders[activeTab]} + className="w-full rounded-lg border border-dark-700/50 bg-dark-900/50 py-1.5 pl-8 pr-8 text-sm text-dark-100 placeholder-dark-500 outline-none transition-colors focus:border-accent-500/50 focus:ring-1 focus:ring-accent-500/30" + /> + {isLoading && ( +
+
+
+ )} +
+
+ + {/* List */} +
+ {activeTab === 'campaign' && renderCampaignList()} + {activeTab === 'partner' && renderPartnerList()} + {activeTab === 'user' && renderUserList()} +
)}
@@ -234,36 +298,53 @@ export function ScopeSelector({ value, onSelect, className }: ScopeSelectorProps ); } - return filteredCampaigns.map((campaign) => ( - - )); +
+ {selected ? ( + + + + ) : ( + 'C' + )} +
+
+

{campaign.name}

+

+ {campaign.start_parameter} + {' / '} + {campaign.direct_users} {t('admin.referralNetwork.scope.users')} +

+
+ + {campaign.is_active + ? t('admin.referralNetwork.scope.active') + : t('admin.referralNetwork.scope.inactive')} + + + ); + }); } function renderPartnerList() { @@ -283,25 +364,42 @@ export function ScopeSelector({ value, onSelect, className }: ScopeSelectorProps ); } - return filteredPartners.map((partner) => ( - - )); + return filteredPartners.map((partner) => { + const selected = isSelected('partner', partner.id); + return ( + + ); + }); } function renderUserList() { @@ -331,28 +429,45 @@ export function ScopeSelector({ value, onSelect, className }: ScopeSelectorProps ); } - return users.map((user) => ( - - )); + return users.map((user) => { + const selected = isSelected('user', user.id); + return ( + + ); + }); } } diff --git a/src/store/referralNetwork.ts b/src/store/referralNetwork.ts index 48295c0..14ffa09 100644 --- a/src/store/referralNetwork.ts +++ b/src/store/referralNetwork.ts @@ -1,6 +1,8 @@ import { create } from 'zustand'; import type { SelectedNode, NetworkFilters, ScopeSelection } from '@/types/referralNetwork'; +const MAX_SCOPE_ITEMS = 50; + const DEFAULT_FILTERS: NetworkFilters = { campaigns: [], partnersOnly: false, @@ -12,14 +14,16 @@ interface ReferralNetworkState { hoveredNodeId: string | null; highlightedNodes: Set; filters: NetworkFilters; - scope: ScopeSelection | null; + scope: ScopeSelection[]; setSelectedNode: (node: SelectedNode) => void; setHoveredNode: (id: string | null) => void; setHighlightedNodes: (nodes: Set) => void; updateFilters: (filters: Partial) => void; resetFilters: () => void; - setScope: (scope: ScopeSelection | null) => void; + addScope: (item: ScopeSelection) => void; + removeScope: (type: ScopeSelection['type'], id: number) => void; + clearScope: () => void; } export const useReferralNetworkStore = create()((set) => ({ @@ -27,12 +31,10 @@ export const useReferralNetworkStore = create()((set) => ( hoveredNodeId: null, highlightedNodes: new Set(), filters: { ...DEFAULT_FILTERS }, - scope: null, + scope: [], setSelectedNode: (node) => set({ selectedNode: node }), - setHoveredNode: (id) => set({ hoveredNodeId: id }), - setHighlightedNodes: (nodes) => set({ highlightedNodes: nodes }), updateFilters: (partial) => @@ -42,9 +44,32 @@ export const useReferralNetworkStore = create()((set) => ( resetFilters: () => set({ filters: { ...DEFAULT_FILTERS } }), - setScope: (scope) => + addScope: (item) => + set((state) => { + const exists = state.scope.some((s) => s.type === item.type && s.id === item.id); + if (exists) return state; + if (state.scope.length >= MAX_SCOPE_ITEMS) return state; + return { + scope: [...state.scope, item], + selectedNode: null, + hoveredNodeId: null, + highlightedNodes: new Set(), + filters: { ...DEFAULT_FILTERS }, + }; + }), + + removeScope: (type, id) => + set((state) => ({ + scope: state.scope.filter((s) => !(s.type === type && s.id === id)), + selectedNode: null, + hoveredNodeId: null, + highlightedNodes: new Set(), + filters: { ...DEFAULT_FILTERS }, + })), + + clearScope: () => set({ - scope, + scope: [], selectedNode: null, hoveredNodeId: null, highlightedNodes: new Set(),