mirror of
https://github.com/chillpadclub/bedolaga-cabinet.git
synced 2026-07-28 09:33:46 +00:00
feat(referral-network): кнопка построения полной реферальной сети
Со времён редизайна со скоуп-селектором граф строится только для выбранных кампаний, партнёров или пользователей, и посмотреть сеть целиком стало нечем. Бэкенд для этого уже есть — GET /cabinet/admin/ referral-network/ отдаёт полный граф и остался неиспользуемым после того, как фронт перешёл на /scoped. Возвращаем к нему доступ: в дропдауне селектора появляется закреплённый пункт «Вся сеть», который грузит граф целиком. Режим взаимоисключающий с выбором чипов — полный граф и так содержит все сущности.
This commit is contained in:
@@ -14,6 +14,11 @@ export const referralNetworkApi = {
|
||||
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> => {
|
||||
const campaignIds = selections.filter((s) => s.type === 'campaign').map((s) => s.id);
|
||||
const partnerIds = selections.filter((s) => s.type === 'partner').map((s) => s.id);
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -1520,7 +1520,10 @@
|
||||
"removeItem": "Удалить {{label}}",
|
||||
"clearAll": "Очистить все выбранные",
|
||||
"addScope": "Добавить область",
|
||||
"maxReached": "Максимум {{max}} элементов"
|
||||
"maxReached": "Максимум {{max}} элементов",
|
||||
"fullNetwork": "Вся сеть",
|
||||
"fullNetworkHint": "Показать граф целиком",
|
||||
"removeFullNetwork": "Выключить режим полной сети"
|
||||
},
|
||||
"loading": "Загрузка графа...",
|
||||
"error": "Ошибка загрузки данных сети",
|
||||
|
||||
@@ -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() {
|
||||
</div>
|
||||
<ScopeSelector
|
||||
value={scope}
|
||||
isFullNetwork={isFullNetwork}
|
||||
onAdd={addScope}
|
||||
onRemove={removeScope}
|
||||
onClear={clearScope}
|
||||
onFullNetworkChange={setFullNetwork}
|
||||
className="min-w-0 flex-1 sm:max-w-xl"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -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<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',
|
||||
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<ScopeType, string> = {
|
||||
const AVATAR_GLYPHS: Record<OptionType, React.ReactNode> = {
|
||||
campaign: 'C',
|
||||
partner: 'P',
|
||||
user: 'U',
|
||||
all: <ShareIcon className="h-4 w-4" />,
|
||||
};
|
||||
|
||||
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
|
||||
<div
|
||||
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 className="min-w-0 flex-1">
|
||||
<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 [activeTab, setActiveTab] = useState<ScopeType>('campaign');
|
||||
const [searchInput, setSearchInput] = useState('');
|
||||
@@ -211,8 +227,24 @@ export function ScopeSelector({ value, onAdd, onRemove, onClear, className }: Sc
|
||||
{/* Chips row + add trigger */}
|
||||
<div className="flex items-center gap-1.5">
|
||||
{/* Selected chips */}
|
||||
{value.length > 0 && (
|
||||
{(isFullNetwork || value.length > 0) && (
|
||||
<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) => (
|
||||
<span
|
||||
key={`${item.type}:${item.id}`}
|
||||
@@ -312,8 +344,18 @@ export function ScopeSelector({ value, onAdd, onRemove, onClear, className }: Sc
|
||||
</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="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 === 'partner' && renderPartnerList()}
|
||||
{activeTab === 'user' && renderUserList()}
|
||||
|
||||
105
src/store/referralNetwork.test.ts
Normal file
105
src/store/referralNetwork.test.ts
Normal 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)]);
|
||||
});
|
||||
});
|
||||
@@ -24,12 +24,14 @@ interface ReferralNetworkState {
|
||||
highlightedNodes: Set<string>;
|
||||
filters: NetworkFilters;
|
||||
scope: ScopeSelection[];
|
||||
isFullNetwork: boolean;
|
||||
|
||||
setSelectedNode: (node: SelectedNode) => void;
|
||||
setHoveredNode: (id: string | null) => void;
|
||||
setHighlightedNodes: (nodes: Set<string>) => void;
|
||||
updateFilters: (filters: Partial<NetworkFilters>) => 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<ReferralNetworkState>()((set) => (
|
||||
highlightedNodes: new Set<string>(),
|
||||
filters: { ...DEFAULT_FILTERS },
|
||||
scope: [],
|
||||
isFullNetwork: false,
|
||||
|
||||
setSelectedNode: (node) => set({ selectedNode: node }),
|
||||
setHoveredNode: (id) => set({ hoveredNodeId: id }),
|
||||
@@ -53,6 +56,16 @@ export const useReferralNetworkStore = create<ReferralNetworkState>()((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<ReferralNetworkState>()((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<ReferralNetworkState>()((set) => (
|
||||
clearScope: () =>
|
||||
set({
|
||||
scope: [],
|
||||
isFullNetwork: false,
|
||||
...resetGraphState(),
|
||||
}),
|
||||
}));
|
||||
|
||||
Reference in New Issue
Block a user