Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions frontend/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions frontend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
"@emotion/react": "*",
"@emotion/styled": "*",
"@reduxjs/toolkit": "*",
"@tanstack/react-query": "^5.100.10",
"antd": "*",
"axios": "*",
"chart.js": "*",
Expand Down
66 changes: 27 additions & 39 deletions frontend/src/components/Applications.tsx
Original file line number Diff line number Diff line change
@@ -1,15 +1,14 @@
import React, { useState, useCallback, useEffect } from 'react';
import React, { useState } from 'react';
import styled from '@emotion/styled';
import dayjs from 'dayjs';
import {
Table, Button, Space, App, Modal, Input, DatePicker, Popconfirm, Tag,
Table, Button, Space, App, Modal, Input, Popconfirm, Tag,
} from 'antd';
import { TableSkeleton, EmptyState } from './ui';
import type { ColumnsType } from 'antd/es/table';
import {
PlusOutlined, ReloadOutlined, DeleteOutlined, FileTextOutlined,
} from '@ant-design/icons';
import { apiInstance } from '../shared/api/instance';
import { useCrmList, useCrmMutation } from '../shared/api/queryHooks';

const Page = styled.div`display:flex;flex-direction:column;gap:18px;padding:8px 0 0;`;
const ToolRow = styled.div`display:flex;align-items:center;gap:12px;flex-wrap:wrap;justify-content:space-between;`;
Expand All @@ -34,46 +33,35 @@ interface Application {

const Applications: React.FC = () => {
const { message } = App.useApp();
const [data, setData] = useState<Application[]>([]);
const [loading, setLoading] = useState(false);
const [modalOpen, setModalOpen] = useState(false);
const [form, setForm] = useState({ client_name: '', topic: '', lawyer_name: '', comment: '' });

const load = useCallback(async () => {
setLoading(true);
try {
const res = await apiInstance.get('/applications');
setData(res.data?.data || res.data || []);
} catch {
setData([]);
} finally {
setLoading(false);
}
}, []);
const { data = [], isPending, isFetching, refetch } = useCrmList<Application>('applications');
const loading = isFetching;

useEffect(() => { load(); }, [load]);
const createMut = useCrmMutation<typeof form>(
{ resource: 'applications', method: 'post' },
{
onSuccess: () => {
message.success('Заявление создано');
setModalOpen(false);
setForm({ client_name: '', topic: '', lawyer_name: '', comment: '' });
},
onError: () => message.error('Ошибка при создании'),
},
);

const handleCreate = async () => {
try {
await apiInstance.post('/applications', form);
message.success('Заявление создано');
setModalOpen(false);
setForm({ client_name: '', topic: '', lawyer_name: '', comment: '' });
load();
} catch {
message.error('Ошибка при создании');
}
};
const deleteMut = useCrmMutation<{ id: number }>(
{ resource: 'applications', method: 'delete', url: ({ id }) => `/applications/${id}` },
{
onSuccess: () => message.success('Удалено'),
onError: () => message.error('Ошибка при удалении'),
},
);

const handleDelete = async (id: number) => {
try {
await apiInstance.delete(`/applications/${id}`);
message.success('Удалено');
load();
} catch {
message.error('Ошибка при удалении');
}
};
const handleCreate = () => createMut.mutate(form);
const handleDelete = (id: number) => deleteMut.mutate({ id });
const load = () => { refetch(); };

const columns: ColumnsType<Application> = [
{ title: 'ФИО клиента', dataIndex: 'client_name', key: 'client_name',
Expand Down Expand Up @@ -107,7 +95,7 @@ const Applications: React.FC = () => {
</Space>
</ToolRow>
<TableCard>
{loading && data.length === 0 ? (
{isPending && data.length === 0 ? (
<TableSkeleton rows={8} cols={columns.length} withToolbar={false} />
) : (
<Table<Application>
Expand Down
19 changes: 18 additions & 1 deletion frontend/src/main.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import './index.css'
import './responsive.css'
import App from './App.tsx'
Expand All @@ -13,8 +14,24 @@ installTunnelAuthPatch();
// Настраиваем перехватчик для автоматического выхода при 401
setupAuthInterceptor();

const queryClient = new QueryClient({
defaultOptions: {
queries: {
// Считаем данные свежими 30 сек — за это время повторное открытие вкладки
// или возврат к компоненту берёт данные из кэша мгновенно, без HTTP-запроса.
staleTime: 30_000,
// Не теребить сервер при каждом фокусе окна (Antd Drawer, переключение таба).
refetchOnWindowFocus: false,
// 1 ретрай — нет смысла долбить упавший endpoint.
retry: 1,
},
},
})

createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
<QueryClientProvider client={queryClient}>
<App />
</QueryClientProvider>
</StrictMode>,
)
111 changes: 111 additions & 0 deletions frontend/src/shared/api/queryHooks.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { renderHook, waitFor } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import React from 'react';
import { useCrmList, useCrmMutation } from './queryHooks';

vi.mock('./instance', () => ({
apiInstance: {
get: vi.fn(),
post: vi.fn(),
delete: vi.fn(),
},
}));

import { apiInstance } from './instance';

function makeWrapper() {
const client = new QueryClient({
defaultOptions: { queries: { retry: false, staleTime: 0 } },
});
const Wrapper: React.FC<{ children: React.ReactNode }> = ({ children }) => (
<QueryClientProvider client={client}>{children}</QueryClientProvider>
);
return { Wrapper, client };
}

describe('useCrmList', () => {
beforeEach(() => {
vi.clearAllMocks();
});

it('returns an array when API returns a plain array', async () => {
(apiInstance.get as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
data: [{ id: 1 }, { id: 2 }],
});
const { Wrapper } = makeWrapper();
const { result } = renderHook(() => useCrmList('applications'), { wrapper: Wrapper });

await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(result.current.data).toEqual([{ id: 1 }, { id: 2 }]);
expect(apiInstance.get).toHaveBeenCalledWith('/applications');
});

it('unwraps {data: [...]} envelope from paginated endpoint', async () => {
(apiInstance.get as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
data: { success: true, data: [{ id: 7 }], total: 1, page: 1, page_size: 50 },
});
const { Wrapper } = makeWrapper();
const { result } = renderHook(() => useCrmList('cases'), { wrapper: Wrapper });

await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(result.current.data).toEqual([{ id: 7 }]);
});

it('returns empty array when API returns unexpected shape', async () => {
(apiInstance.get as ReturnType<typeof vi.fn>).mockResolvedValueOnce({ data: null });
const { Wrapper } = makeWrapper();
const { result } = renderHook(() => useCrmList('cases'), { wrapper: Wrapper });
await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(result.current.data).toEqual([]);
});
});

describe('useCrmMutation', () => {
beforeEach(() => {
vi.clearAllMocks();
});

it('invalidates the list cache after a successful POST', async () => {
(apiInstance.get as ReturnType<typeof vi.fn>)
.mockResolvedValueOnce({ data: [{ id: 1 }] })
.mockResolvedValueOnce({ data: [{ id: 1 }, { id: 2 }] });
(apiInstance.post as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
data: { success: true },
});

const { Wrapper } = makeWrapper();
const { result } = renderHook(
() => ({
list: useCrmList<{ id: number }>('applications'),
create: useCrmMutation<{ topic: string }>({ resource: 'applications', method: 'post' }),
}),
{ wrapper: Wrapper },
);

await waitFor(() => expect(result.current.list.isSuccess).toBe(true));
expect(result.current.list.data?.length).toBe(1);

result.current.create.mutate({ topic: 'Test' });

await waitFor(() => expect(apiInstance.get).toHaveBeenCalledTimes(2));
await waitFor(() => expect(result.current.list.data?.length).toBe(2));
});

it('builds custom URL for DELETE via url() callback', async () => {
(apiInstance.delete as ReturnType<typeof vi.fn>).mockResolvedValueOnce({ data: {} });
const { Wrapper } = makeWrapper();
const { result } = renderHook(
() =>
useCrmMutation<{ id: number }>({
resource: 'applications',
method: 'delete',
url: ({ id }) => `/applications/${id}`,
}),
{ wrapper: Wrapper },
);

result.current.mutate({ id: 42 });
await waitFor(() => expect(apiInstance.delete).toHaveBeenCalledWith('/applications/42'));
});
});
78 changes: 78 additions & 0 deletions frontend/src/shared/api/queryHooks.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
/**
* Тонкие обёртки над TanStack Query для типовых CRM-эндпоинтов.
*
* Делают три вещи:
* 1) Кэшируют ответ списка по ключу [resource] — при переключении вкладок
* данные показываются мгновенно из кэша, в фоне обновляются.
* 2) `useCrmList` нормализует ответ — бэк может возвращать либо массив,
* либо `{ success, data }`, либо `{ data, total, page, ... }` (после
* перехода на пагинацию в PR #17).
* 3) `useCrmMutation` после успешного create/update/delete инвалидирует
* соответствующий list-кэш, чтобы UI сразу подтянул свежее состояние.
*/
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import type { UseMutationOptions, UseQueryOptions } from '@tanstack/react-query';
import { apiInstance } from './instance';

type ListResponse<T> = T[] | { data?: T[]; items?: T[] };

function normalizeList<T>(payload: ListResponse<T> | unknown): T[] {
if (Array.isArray(payload)) return payload as T[];
const obj = payload as { data?: T[]; items?: T[] } | null | undefined;
if (obj && Array.isArray(obj.data)) return obj.data;
if (obj && Array.isArray(obj.items)) return obj.items;
return [];
}

export function useCrmList<T>(
resource: string,
options?: Omit<UseQueryOptions<T[], Error, T[], string[]>, 'queryKey' | 'queryFn'>,
) {
return useQuery<T[], Error, T[], string[]>({
queryKey: [resource],
queryFn: async () => {
const res = await apiInstance.get(`/${resource}`);
return normalizeList<T>(res.data);
},
...options,
});
}

type Method = 'post' | 'put' | 'patch' | 'delete';

interface CrmMutationParams<TVars> {
resource: string;
method: Method;
url?: (vars: TVars) => string;
}

export function useCrmMutation<TVars = unknown, TResp = unknown>(
{ resource, method, url }: CrmMutationParams<TVars>,
options?: UseMutationOptions<TResp, Error, TVars>,
) {
const qc = useQueryClient();
// Важно: сначала разлапачиваем пользовательские options, а затем
// навешиваем свой onSuccess поверх — иначе options?.onSuccess затирает
// нашу инвалидацию кэша и таблица не обновляется после мутации.
const { onSuccess: userOnSuccess, ...rest } = options || {};
return useMutation<TResp, Error, TVars>({
mutationFn: async (vars: TVars) => {
const path = url ? url(vars) : `/${resource}`;
const cfg = method === 'delete' || method === 'get' ? undefined : (vars as object);
const res =
method === 'delete'
? await apiInstance.delete(path)
: method === 'post'
? await apiInstance.post(path, cfg)
: method === 'put'
? await apiInstance.put(path, cfg)
: await apiInstance.patch(path, cfg);
return res.data as TResp;
},
...rest,
onSuccess: (data, variables, context) => {
qc.invalidateQueries({ queryKey: [resource] });
userOnSuccess?.(data, variables, context);
},
});
}
Loading