From 19facf2ba968212ba9f1bb974ce886e9facc348f Mon Sep 17 00:00:00 2001 From: kewldan <40865857+kewldan@users.noreply.github.com> Date: Thu, 16 Jul 2026 15:32:17 +0300 Subject: [PATCH] =?UTF-8?q?feat(referral-network):=20=D0=BA=D0=BD=D0=BE?= =?UTF-8?q?=D0=BF=D0=BA=D0=B0=20=D0=BF=D0=BE=D1=81=D1=82=D1=80=D0=BE=D0=B5?= =?UTF-8?q?=D0=BD=D0=B8=D1=8F=20=D0=BF=D0=BE=D0=BB=D0=BD=D0=BE=D0=B9=20?= =?UTF-8?q?=D1=80=D0=B5=D1=84=D0=B5=D1=80=D0=B0=D0=BB=D1=8C=D0=BD=D0=BE?= =?UTF-8?q?=D0=B9=20=D1=81=D0=B5=D1=82=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Со времён редизайна со скоуп-селектором граф строится только для выбранных кампаний, партнёров или пользователей, и посмотреть сеть целиком стало нечем. Бэкенд для этого уже есть — GET /cabinet/admin/ referral-network/ отдаёт полный граф и остался неиспользуемым после того, как фронт перешёл на /scoped. Возвращаем к нему доступ: в дропдауне селектора появляется закреплённый пункт «Вся сеть», который грузит граф целиком. Режим взаимоисключающий с выбором чипов — полный граф и так содержит все сущности. --- src/api/referralNetwork.ts | 5 + src/locales/en.json | 5 +- src/locales/ru.json | 5 +- src/pages/ReferralNetwork/ReferralNetwork.tsx | 13 ++- .../components/ScopeSelector.tsx | 58 ++++++++-- src/store/referralNetwork.test.ts | 105 ++++++++++++++++++ src/store/referralNetwork.ts | 15 +++ 7 files changed, 193 insertions(+), 13 deletions(-) create mode 100644 src/store/referralNetwork.test.ts diff --git a/src/api/referralNetwork.ts b/src/api/referralNetwork.ts index 9751d08..5f9a592 100644 --- a/src/api/referralNetwork.ts +++ b/src/api/referralNetwork.ts @@ -14,6 +14,11 @@ export const referralNetworkApi = { return response.data; }, + getFullGraph: async (): Promise => { + const response = await apiClient.get('/cabinet/admin/referral-network/'); + return response.data; + }, + 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); diff --git a/src/locales/en.json b/src/locales/en.json index 160c585..342c3af 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -1498,7 +1498,10 @@ "removeItem": "Remove {{label}}", "clearAll": "Clear all selections", "addScope": "Add scope", - "maxReached": "Maximum {{max}} items" + "maxReached": "Maximum {{max}} items", + "fullNetwork": "Full network", + "fullNetworkHint": "Show the entire graph", + "removeFullNetwork": "Turn off full network mode" }, "loading": "Loading graph...", "error": "Failed to load network data", diff --git a/src/locales/ru.json b/src/locales/ru.json index 79f6820..ecd7a1d 100644 --- a/src/locales/ru.json +++ b/src/locales/ru.json @@ -1520,7 +1520,10 @@ "removeItem": "Удалить {{label}}", "clearAll": "Очистить все выбранные", "addScope": "Добавить область", - "maxReached": "Максимум {{max}} элементов" + "maxReached": "Максимум {{max}} элементов", + "fullNetwork": "Вся сеть", + "fullNetworkHint": "Показать граф целиком", + "removeFullNetwork": "Выключить режим полной сети" }, "loading": "Загрузка графа...", "error": "Ошибка загрузки данных сети", diff --git a/src/pages/ReferralNetwork/ReferralNetwork.tsx b/src/pages/ReferralNetwork/ReferralNetwork.tsx index 21ed65f..1e13e3a 100644 --- a/src/pages/ReferralNetwork/ReferralNetwork.tsx +++ b/src/pages/ReferralNetwork/ReferralNetwork.tsx @@ -17,21 +17,26 @@ export function ReferralNetwork() { const { t } = useTranslation(); const selectedNode = useReferralNetworkStore((s) => s.selectedNode); const scope = useReferralNetworkStore((s) => s.scope); + const isFullNetwork = useReferralNetworkStore((s) => s.isFullNetwork); const addScope = useReferralNetworkStore((s) => s.addScope); const removeScope = useReferralNetworkStore((s) => s.removeScope); const clearScope = useReferralNetworkStore((s) => s.clearScope); + const setFullNetwork = useReferralNetworkStore((s) => s.setFullNetwork); const { mobile: mobileHeaderHeight, bottomSafeArea } = useHeaderHeight(); - const hasScope = scope.length > 0; + const hasScope = isFullNetwork || scope.length > 0; const { data: networkData, isLoading, isError, } = useQuery({ - queryKey: ['referral-network', 'scoped', scope.map((s) => `${s.type}:${s.id}`).sort()], - queryFn: () => referralNetworkApi.getScopedGraph(scope), + queryKey: isFullNetwork + ? ['referral-network', 'full'] + : ['referral-network', 'scoped', scope.map((s) => `${s.type}:${s.id}`).sort()], + queryFn: () => + isFullNetwork ? referralNetworkApi.getFullGraph() : referralNetworkApi.getScopedGraph(scope), enabled: hasScope, staleTime: 120_000, }); @@ -60,9 +65,11 @@ export function ReferralNetwork() { diff --git a/src/pages/ReferralNetwork/components/ScopeSelector.tsx b/src/pages/ReferralNetwork/components/ScopeSelector.tsx index fd35b6e..0036d10 100644 --- a/src/pages/ReferralNetwork/components/ScopeSelector.tsx +++ b/src/pages/ReferralNetwork/components/ScopeSelector.tsx @@ -2,33 +2,41 @@ import { useState, useEffect, useRef, useMemo, useCallback } from 'react'; import { useTranslation } from 'react-i18next'; import { useQuery } from '@tanstack/react-query'; import { referralNetworkApi } from '@/api/referralNetwork'; -import { CheckIcon, CloseIcon, PlusIcon, SearchIcon } from '@/components/icons'; +import { CheckIcon, CloseIcon, PlusIcon, SearchIcon, ShareIcon } from '@/components/icons'; import { MAX_SCOPE_ITEMS } from '@/store/referralNetwork'; import type { ScopeSelection, ScopeType } from '@/types/referralNetwork'; interface ScopeSelectorProps { value: ScopeSelection[]; + isFullNetwork: boolean; onAdd: (selection: ScopeSelection) => void; onRemove: (type: ScopeSelection['type'], id: number) => void; onClear: () => void; + onFullNetworkChange: (enabled: boolean) => void; className?: string; } const SCOPE_TABS: ScopeType[] = ['campaign', 'partner', 'user']; -const CHIP_COLORS: Record = { +// 'all' is the full-network mode rather than a scope entity, so it gets a +// neutral chip instead of one of the three entity colours. +type OptionType = ScopeType | 'all'; + +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', + all: 'bg-dark-700 text-dark-100', }; // Reuse CHIP_COLORS for avatar backgrounds (same palette) const AVATAR_COLORS = CHIP_COLORS; -const AVATAR_LETTERS: Record = { +const AVATAR_GLYPHS: Record = { campaign: 'C', partner: 'P', user: 'U', + all: , }; function Spinner({ size = 'h-5 w-5' }: { size?: string }) { @@ -44,7 +52,7 @@ function EmptyMessage({ text }: { text: string }) { } interface ScopeListItemProps { - type: ScopeType; + type: OptionType; selected: boolean; onClick: () => void; title: string; @@ -65,7 +73,7 @@ function ScopeListItem({ type, selected, onClick, title, subtitle, badge }: Scop
- {selected ? : AVATAR_LETTERS[type]} + {selected ? : AVATAR_GLYPHS[type]}

{title}

@@ -76,7 +84,15 @@ function ScopeListItem({ type, selected, onClick, title, subtitle, badge }: Scop ); } -export function ScopeSelector({ value, onAdd, onRemove, onClear, className }: ScopeSelectorProps) { +export function ScopeSelector({ + value, + isFullNetwork, + onAdd, + onRemove, + onClear, + onFullNetworkChange, + className, +}: ScopeSelectorProps) { const { t } = useTranslation(); const [activeTab, setActiveTab] = useState('campaign'); const [searchInput, setSearchInput] = useState(''); @@ -211,8 +227,24 @@ export function ScopeSelector({ value, onAdd, onRemove, onClear, className }: Sc {/* Chips row + add trigger */}
{/* Selected chips */} - {value.length > 0 && ( + {(isFullNetwork || value.length > 0) && (
+ {isFullNetwork && ( + + + {t('admin.referralNetwork.scope.fullNetwork')} + + + + )} {value.map((item) => (
- {/* List */} + {/* List — the full-network option leads every tab, since it is a mode + rather than an entity of the currently active tab. */}
+
+ onFullNetworkChange(!isFullNetwork)} + title={t('admin.referralNetwork.scope.fullNetwork')} + subtitle={t('admin.referralNetwork.scope.fullNetworkHint')} + /> +
{activeTab === 'campaign' && renderCampaignList()} {activeTab === 'partner' && renderPartnerList()} {activeTab === 'user' && renderUserList()} diff --git a/src/store/referralNetwork.test.ts b/src/store/referralNetwork.test.ts new file mode 100644 index 0000000..2f842f3 --- /dev/null +++ b/src/store/referralNetwork.test.ts @@ -0,0 +1,105 @@ +import { beforeEach, describe, expect, it } from 'vitest'; +import { MAX_SCOPE_ITEMS, useReferralNetworkStore } from './referralNetwork'; +import type { ScopeSelection } from '@/types/referralNetwork'; + +const INITIAL_STATE = useReferralNetworkStore.getState(); + +function campaign(id: number): ScopeSelection { + return { type: 'campaign', id, label: `Campaign ${id}` }; +} + +describe('referralNetwork store', () => { + beforeEach(() => { + useReferralNetworkStore.setState(INITIAL_STATE, true); + }); + + it('starts with no scope and full network off', () => { + const state = useReferralNetworkStore.getState(); + expect(state.scope).toEqual([]); + expect(state.isFullNetwork).toBe(false); + }); + + it('drops the selected scope when full network is enabled', () => { + const { addScope, setFullNetwork } = useReferralNetworkStore.getState(); + addScope(campaign(1)); + addScope(campaign(2)); + + setFullNetwork(true); + + const state = useReferralNetworkStore.getState(); + expect(state.isFullNetwork).toBe(true); + expect(state.scope).toEqual([]); + }); + + it('leaves full network mode when a scope item is added', () => { + const { setFullNetwork, addScope } = useReferralNetworkStore.getState(); + setFullNetwork(true); + + addScope(campaign(1)); + + const state = useReferralNetworkStore.getState(); + expect(state.isFullNetwork).toBe(false); + expect(state.scope).toEqual([campaign(1)]); + }); + + it('resets the graph selection when switching to full network', () => { + const { setSelectedNode, setHighlightedNodes, setFullNetwork } = + useReferralNetworkStore.getState(); + setSelectedNode({ type: 'user', id: 7 }); + setHighlightedNodes(new Set(['user_7'])); + + setFullNetwork(true); + + const state = useReferralNetworkStore.getState(); + expect(state.selectedNode).toBeNull(); + expect(state.highlightedNodes.size).toBe(0); + }); + + it('turns full network off without leaving a stale scope', () => { + const { setFullNetwork } = useReferralNetworkStore.getState(); + setFullNetwork(true); + + setFullNetwork(false); + + const state = useReferralNetworkStore.getState(); + expect(state.isFullNetwork).toBe(false); + expect(state.scope).toEqual([]); + }); + + it('clears both the scope and full network mode', () => { + const { setFullNetwork, clearScope } = useReferralNetworkStore.getState(); + setFullNetwork(true); + + clearScope(); + + const state = useReferralNetworkStore.getState(); + expect(state.isFullNetwork).toBe(false); + expect(state.scope).toEqual([]); + }); + + it('ignores duplicate scope items and keeps full network off', () => { + const { addScope } = useReferralNetworkStore.getState(); + addScope(campaign(1)); + addScope(campaign(1)); + + const state = useReferralNetworkStore.getState(); + expect(state.scope).toEqual([campaign(1)]); + expect(state.isFullNetwork).toBe(false); + }); + + it('does not exit full network mode when the scope is already full', () => { + const { addScope, setFullNetwork } = useReferralNetworkStore.getState(); + for (let id = 1; id <= MAX_SCOPE_ITEMS; id++) { + addScope(campaign(id)); + } + setFullNetwork(true); + + // The cap is per-scope; with the scope dropped by full-network mode there is + // nothing to reject, so this add must switch the mode off and take effect. + addScope(campaign(999)); + + const state = useReferralNetworkStore.getState(); + expect(state.isFullNetwork).toBe(false); + expect(state.scope).toEqual([campaign(999)]); + }); +}); diff --git a/src/store/referralNetwork.ts b/src/store/referralNetwork.ts index 8501fdc..4b722ba 100644 --- a/src/store/referralNetwork.ts +++ b/src/store/referralNetwork.ts @@ -24,12 +24,14 @@ interface ReferralNetworkState { highlightedNodes: Set; filters: NetworkFilters; scope: ScopeSelection[]; + isFullNetwork: boolean; setSelectedNode: (node: SelectedNode) => void; setHoveredNode: (id: string | null) => void; setHighlightedNodes: (nodes: Set) => void; updateFilters: (filters: Partial) => void; resetFilters: () => void; + setFullNetwork: (enabled: boolean) => void; addScope: (item: ScopeSelection) => void; removeScope: (type: ScopeSelection['type'], id: number) => void; clearScope: () => void; @@ -41,6 +43,7 @@ export const useReferralNetworkStore = create()((set) => ( highlightedNodes: new Set(), filters: { ...DEFAULT_FILTERS }, scope: [], + isFullNetwork: false, setSelectedNode: (node) => set({ selectedNode: node }), setHoveredNode: (id) => set({ hoveredNodeId: id }), @@ -53,6 +56,16 @@ export const useReferralNetworkStore = create()((set) => ( resetFilters: () => set({ filters: { ...DEFAULT_FILTERS } }), + // Full network and a scope selection are mutually exclusive: the whole graph + // already contains every campaign, partner and user, so narrowing it by chips + // would be meaningless. Each mode therefore drops the other. + setFullNetwork: (enabled) => + set({ + isFullNetwork: enabled, + scope: [], + ...resetGraphState(), + }), + addScope: (item) => set((state) => { const exists = state.scope.some((s) => s.type === item.type && s.id === item.id); @@ -60,6 +73,7 @@ export const useReferralNetworkStore = create()((set) => ( if (state.scope.length >= MAX_SCOPE_ITEMS) return state; return { scope: [...state.scope, item], + isFullNetwork: false, ...resetGraphState(), }; }), @@ -76,6 +90,7 @@ export const useReferralNetworkStore = create()((set) => ( clearScope: () => set({ scope: [], + isFullNetwork: false, ...resetGraphState(), }), }));