feat(referral-network): кнопка построения полной реферальной сети

Со времён редизайна со скоуп-селектором граф строится только для
выбранных кампаний, партнёров или пользователей, и посмотреть сеть
целиком стало нечем. Бэкенд для этого уже есть — GET /cabinet/admin/
referral-network/ отдаёт полный граф и остался неиспользуемым после
того, как фронт перешёл на /scoped.

Возвращаем к нему доступ: в дропдауне селектора появляется закреплённый
пункт «Вся сеть», который грузит граф целиком. Режим взаимоисключающий
с выбором чипов — полный граф и так содержит все сущности.
This commit is contained in:
kewldan
2026-07-16 15:32:17 +03:00
parent 9f909b0f2c
commit 19facf2ba9
7 changed files with 193 additions and 13 deletions

View File

@@ -14,6 +14,11 @@ export const referralNetworkApi = {
return response.data; return response.data;
}, },
getFullGraph: async (): Promise<NetworkGraphData> => {
const response = await apiClient.get('/cabinet/admin/referral-network/');
return response.data;
},
getScopedGraph: async (selections: ScopeSelection[]): Promise<NetworkGraphData> => { getScopedGraph: async (selections: ScopeSelection[]): Promise<NetworkGraphData> => {
const campaignIds = selections.filter((s) => s.type === 'campaign').map((s) => s.id); 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 partnerIds = selections.filter((s) => s.type === 'partner').map((s) => s.id);

View File

@@ -1498,7 +1498,10 @@
"removeItem": "Remove {{label}}", "removeItem": "Remove {{label}}",
"clearAll": "Clear all selections", "clearAll": "Clear all selections",
"addScope": "Add scope", "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...", "loading": "Loading graph...",
"error": "Failed to load network data", "error": "Failed to load network data",

View File

@@ -1520,7 +1520,10 @@
"removeItem": "Удалить {{label}}", "removeItem": "Удалить {{label}}",
"clearAll": "Очистить все выбранные", "clearAll": "Очистить все выбранные",
"addScope": "Добавить область", "addScope": "Добавить область",
"maxReached": "Максимум {{max}} элементов" "maxReached": "Максимум {{max}} элементов",
"fullNetwork": "Вся сеть",
"fullNetworkHint": "Показать граф целиком",
"removeFullNetwork": "Выключить режим полной сети"
}, },
"loading": "Загрузка графа...", "loading": "Загрузка графа...",
"error": "Ошибка загрузки данных сети", "error": "Ошибка загрузки данных сети",

View File

@@ -17,21 +17,26 @@ export function ReferralNetwork() {
const { t } = useTranslation(); const { t } = useTranslation();
const selectedNode = useReferralNetworkStore((s) => s.selectedNode); const selectedNode = useReferralNetworkStore((s) => s.selectedNode);
const scope = useReferralNetworkStore((s) => s.scope); const scope = useReferralNetworkStore((s) => s.scope);
const isFullNetwork = useReferralNetworkStore((s) => s.isFullNetwork);
const addScope = useReferralNetworkStore((s) => s.addScope); const addScope = useReferralNetworkStore((s) => s.addScope);
const removeScope = useReferralNetworkStore((s) => s.removeScope); const removeScope = useReferralNetworkStore((s) => s.removeScope);
const clearScope = useReferralNetworkStore((s) => s.clearScope); const clearScope = useReferralNetworkStore((s) => s.clearScope);
const setFullNetwork = useReferralNetworkStore((s) => s.setFullNetwork);
const { mobile: mobileHeaderHeight, bottomSafeArea } = useHeaderHeight(); const { mobile: mobileHeaderHeight, bottomSafeArea } = useHeaderHeight();
const hasScope = scope.length > 0; const hasScope = isFullNetwork || scope.length > 0;
const { const {
data: networkData, data: networkData,
isLoading, isLoading,
isError, isError,
} = useQuery({ } = useQuery({
queryKey: ['referral-network', 'scoped', scope.map((s) => `${s.type}:${s.id}`).sort()], queryKey: isFullNetwork
queryFn: () => referralNetworkApi.getScopedGraph(scope), ? ['referral-network', 'full']
: ['referral-network', 'scoped', scope.map((s) => `${s.type}:${s.id}`).sort()],
queryFn: () =>
isFullNetwork ? referralNetworkApi.getFullGraph() : referralNetworkApi.getScopedGraph(scope),
enabled: hasScope, enabled: hasScope,
staleTime: 120_000, staleTime: 120_000,
}); });
@@ -60,9 +65,11 @@ export function ReferralNetwork() {
</div> </div>
<ScopeSelector <ScopeSelector
value={scope} value={scope}
isFullNetwork={isFullNetwork}
onAdd={addScope} onAdd={addScope}
onRemove={removeScope} onRemove={removeScope}
onClear={clearScope} onClear={clearScope}
onFullNetworkChange={setFullNetwork}
className="min-w-0 flex-1 sm:max-w-xl" className="min-w-0 flex-1 sm:max-w-xl"
/> />
</div> </div>

View File

@@ -2,33 +2,41 @@ import { useState, useEffect, useRef, useMemo, useCallback } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { referralNetworkApi } from '@/api/referralNetwork'; 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 { MAX_SCOPE_ITEMS } from '@/store/referralNetwork';
import type { ScopeSelection, ScopeType } from '@/types/referralNetwork'; import type { ScopeSelection, ScopeType } from '@/types/referralNetwork';
interface ScopeSelectorProps { interface ScopeSelectorProps {
value: ScopeSelection[]; value: ScopeSelection[];
isFullNetwork: boolean;
onAdd: (selection: ScopeSelection) => void; onAdd: (selection: ScopeSelection) => void;
onRemove: (type: ScopeSelection['type'], id: number) => void; onRemove: (type: ScopeSelection['type'], id: number) => void;
onClear: () => void; onClear: () => void;
onFullNetworkChange: (enabled: boolean) => void;
className?: string; className?: string;
} }
const SCOPE_TABS: ScopeType[] = ['campaign', 'partner', 'user']; const SCOPE_TABS: ScopeType[] = ['campaign', 'partner', 'user'];
const CHIP_COLORS: Record<ScopeType, string> = { // '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<OptionType, string> = {
campaign: 'bg-success-500/20 text-success-400', campaign: 'bg-success-500/20 text-success-400',
partner: 'bg-warning-500/20 text-warning-400', partner: 'bg-warning-500/20 text-warning-400',
user: 'bg-accent-500/20 text-accent-400', user: 'bg-accent-500/20 text-accent-400',
all: 'bg-dark-700 text-dark-100',
}; };
// Reuse CHIP_COLORS for avatar backgrounds (same palette) // Reuse CHIP_COLORS for avatar backgrounds (same palette)
const AVATAR_COLORS = CHIP_COLORS; const AVATAR_COLORS = CHIP_COLORS;
const AVATAR_LETTERS: Record<ScopeType, string> = { const AVATAR_GLYPHS: Record<OptionType, React.ReactNode> = {
campaign: 'C', campaign: 'C',
partner: 'P', partner: 'P',
user: 'U', user: 'U',
all: <ShareIcon className="h-4 w-4" />,
}; };
function Spinner({ size = 'h-5 w-5' }: { size?: string }) { function Spinner({ size = 'h-5 w-5' }: { size?: string }) {
@@ -44,7 +52,7 @@ function EmptyMessage({ text }: { text: string }) {
} }
interface ScopeListItemProps { interface ScopeListItemProps {
type: ScopeType; type: OptionType;
selected: boolean; selected: boolean;
onClick: () => void; onClick: () => void;
title: string; title: string;
@@ -65,7 +73,7 @@ function ScopeListItem({ type, selected, onClick, title, subtitle, badge }: Scop
<div <div
className={`flex h-7 w-7 shrink-0 items-center justify-center rounded-full text-xs font-medium ${AVATAR_COLORS[type]}`} className={`flex h-7 w-7 shrink-0 items-center justify-center rounded-full text-xs font-medium ${AVATAR_COLORS[type]}`}
> >
{selected ? <CheckIcon /> : AVATAR_LETTERS[type]} {selected ? <CheckIcon /> : AVATAR_GLYPHS[type]}
</div> </div>
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium text-dark-100">{title}</p> <p className="truncate text-sm font-medium text-dark-100">{title}</p>
@@ -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 { t } = useTranslation();
const [activeTab, setActiveTab] = useState<ScopeType>('campaign'); const [activeTab, setActiveTab] = useState<ScopeType>('campaign');
const [searchInput, setSearchInput] = useState(''); const [searchInput, setSearchInput] = useState('');
@@ -211,8 +227,24 @@ export function ScopeSelector({ value, onAdd, onRemove, onClear, className }: Sc
{/* Chips row + add trigger */} {/* Chips row + add trigger */}
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-1.5">
{/* Selected chips */} {/* Selected chips */}
{value.length > 0 && ( {(isFullNetwork || value.length > 0) && (
<div className="flex min-w-0 flex-1 flex-wrap items-center gap-1"> <div className="flex min-w-0 flex-1 flex-wrap items-center gap-1">
{isFullNetwork && (
<span
className={`inline-flex shrink-0 items-center gap-1 rounded-md px-2 py-0.5 text-xs font-medium ${CHIP_COLORS.all}`}
>
<span className="max-w-[120px] truncate">
{t('admin.referralNetwork.scope.fullNetwork')}
</span>
<button
onClick={() => onFullNetworkChange(false)}
aria-label={t('admin.referralNetwork.scope.removeFullNetwork')}
className="ml-0.5 rounded-sm p-0.5 transition-colors hover:bg-white/10"
>
<CloseIcon className="h-3 w-3" />
</button>
</span>
)}
{value.map((item) => ( {value.map((item) => (
<span <span
key={`${item.type}:${item.id}`} key={`${item.type}:${item.id}`}
@@ -312,8 +344,18 @@ export function ScopeSelector({ value, onAdd, onRemove, onClear, className }: Sc
</div> </div>
</div> </div>
{/* List */} {/* List — the full-network option leads every tab, since it is a mode
rather than an entity of the currently active tab. */}
<div className="max-h-64 overflow-y-auto" role="listbox" aria-multiselectable="true"> <div className="max-h-64 overflow-y-auto" role="listbox" aria-multiselectable="true">
<div className="border-b border-dark-700/50">
<ScopeListItem
type="all"
selected={isFullNetwork}
onClick={() => onFullNetworkChange(!isFullNetwork)}
title={t('admin.referralNetwork.scope.fullNetwork')}
subtitle={t('admin.referralNetwork.scope.fullNetworkHint')}
/>
</div>
{activeTab === 'campaign' && renderCampaignList()} {activeTab === 'campaign' && renderCampaignList()}
{activeTab === 'partner' && renderPartnerList()} {activeTab === 'partner' && renderPartnerList()}
{activeTab === 'user' && renderUserList()} {activeTab === 'user' && renderUserList()}

View File

@@ -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)]);
});
});

View File

@@ -24,12 +24,14 @@ interface ReferralNetworkState {
highlightedNodes: Set<string>; highlightedNodes: Set<string>;
filters: NetworkFilters; filters: NetworkFilters;
scope: ScopeSelection[]; scope: ScopeSelection[];
isFullNetwork: boolean;
setSelectedNode: (node: SelectedNode) => void; setSelectedNode: (node: SelectedNode) => void;
setHoveredNode: (id: string | null) => void; setHoveredNode: (id: string | null) => void;
setHighlightedNodes: (nodes: Set<string>) => void; setHighlightedNodes: (nodes: Set<string>) => void;
updateFilters: (filters: Partial<NetworkFilters>) => void; updateFilters: (filters: Partial<NetworkFilters>) => void;
resetFilters: () => void; resetFilters: () => void;
setFullNetwork: (enabled: boolean) => void;
addScope: (item: ScopeSelection) => void; addScope: (item: ScopeSelection) => void;
removeScope: (type: ScopeSelection['type'], id: number) => void; removeScope: (type: ScopeSelection['type'], id: number) => void;
clearScope: () => void; clearScope: () => void;
@@ -41,6 +43,7 @@ export const useReferralNetworkStore = create<ReferralNetworkState>()((set) => (
highlightedNodes: new Set<string>(), highlightedNodes: new Set<string>(),
filters: { ...DEFAULT_FILTERS }, filters: { ...DEFAULT_FILTERS },
scope: [], scope: [],
isFullNetwork: false,
setSelectedNode: (node) => set({ selectedNode: node }), setSelectedNode: (node) => set({ selectedNode: node }),
setHoveredNode: (id) => set({ hoveredNodeId: id }), setHoveredNode: (id) => set({ hoveredNodeId: id }),
@@ -53,6 +56,16 @@ export const useReferralNetworkStore = create<ReferralNetworkState>()((set) => (
resetFilters: () => set({ filters: { ...DEFAULT_FILTERS } }), 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) => addScope: (item) =>
set((state) => { set((state) => {
const exists = state.scope.some((s) => s.type === item.type && s.id === item.id); const exists = state.scope.some((s) => s.type === item.type && s.id === item.id);
@@ -60,6 +73,7 @@ export const useReferralNetworkStore = create<ReferralNetworkState>()((set) => (
if (state.scope.length >= MAX_SCOPE_ITEMS) return state; if (state.scope.length >= MAX_SCOPE_ITEMS) return state;
return { return {
scope: [...state.scope, item], scope: [...state.scope, item],
isFullNetwork: false,
...resetGraphState(), ...resetGraphState(),
}; };
}), }),
@@ -76,6 +90,7 @@ export const useReferralNetworkStore = create<ReferralNetworkState>()((set) => (
clearScope: () => clearScope: () =>
set({ set({
scope: [], scope: [],
isFullNetwork: false,
...resetGraphState(), ...resetGraphState(),
}), }),
})); }));