From 7b2a49609b47d38a96a48ef1f3be91ef4bb936e3 Mon Sep 17 00:00:00 2001 From: rodielavender Date: Wed, 13 May 2026 23:44:25 +0000 Subject: [PATCH 1/3] =?UTF-8?q?feat(frontend):=20TanStack=20Query=20?= =?UTF-8?q?=E2=80=94=20=D0=BA=D1=8D=D1=88=20CRM-=D1=81=D0=BF=D0=B8=D1=81?= =?UTF-8?q?=D0=BA=D0=BE=D0=B2=20+=20Applications=20=D0=BC=D0=B8=D0=B3?= =?UTF-8?q?=D1=80=D0=B8=D1=80=D0=BE=D0=B2=D0=B0=D0=BD=20=D0=BD=D0=B0=20?= =?UTF-8?q?=D1=85=D1=83=D0=BA=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/package-lock.json | 27 +++++ frontend/package.json | 1 + frontend/src/components/Applications.tsx | 63 +++++------ frontend/src/main.tsx | 19 +++- frontend/src/shared/api/queryHooks.test.tsx | 111 ++++++++++++++++++++ frontend/src/shared/api/queryHooks.ts | 74 +++++++++++++ 6 files changed, 256 insertions(+), 39 deletions(-) create mode 100644 frontend/src/shared/api/queryHooks.test.tsx create mode 100644 frontend/src/shared/api/queryHooks.ts diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 343cd6f..0685f82 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -12,6 +12,7 @@ "@emotion/react": "*", "@emotion/styled": "*", "@reduxjs/toolkit": "*", + "@tanstack/react-query": "^5.100.10", "antd": "*", "axios": "*", "chart.js": "*", @@ -1949,6 +1950,32 @@ "dev": true, "license": "MIT" }, + "node_modules/@tanstack/query-core": { + "version": "5.100.10", + "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.100.10.tgz", + "integrity": "sha512-8UR0yJR+GiQ40m3lPhUr0xbfAupe6GSQiksSBSa9SM2NjezFyxXCIA69/lz8cSoNKZLrw1/PktIyQBJcVeMi3w==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/react-query": { + "version": "5.100.10", + "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.100.10.tgz", + "integrity": "sha512-FLaZf2RCrA/Zgp4aiu5tG3TyasTRO7aZ99skxQpr3Hg/zXOhu6yq5FZCYQ/tRaJtM9ylnoK8tFK7PolXQadv6Q==", + "license": "MIT", + "dependencies": { + "@tanstack/query-core": "5.100.10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^18 || ^19" + } + }, "node_modules/@testing-library/dom": { "version": "10.4.1", "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", diff --git a/frontend/package.json b/frontend/package.json index b76c0b8..90c4af8 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -18,6 +18,7 @@ "@emotion/react": "*", "@emotion/styled": "*", "@reduxjs/toolkit": "*", + "@tanstack/react-query": "^5.100.10", "antd": "*", "axios": "*", "chart.js": "*", diff --git a/frontend/src/components/Applications.tsx b/frontend/src/components/Applications.tsx index 50a0181..5766ede 100644 --- a/frontend/src/components/Applications.tsx +++ b/frontend/src/components/Applications.tsx @@ -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;`; @@ -34,46 +33,34 @@ interface Application { const Applications: React.FC = () => { const { message } = App.useApp(); - const [data, setData] = useState([]); - 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 = [], isFetching: loading, refetch } = useCrmList('applications'); - useEffect(() => { load(); }, [load]); + const createMut = useCrmMutation( + { 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 = [ { title: 'ФИО клиента', dataIndex: 'client_name', key: 'client_name', diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index 8963b7d..0f5a61f 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -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' @@ -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( - + + + , ) diff --git a/frontend/src/shared/api/queryHooks.test.tsx b/frontend/src/shared/api/queryHooks.test.tsx new file mode 100644 index 0000000..dcae803 --- /dev/null +++ b/frontend/src/shared/api/queryHooks.test.tsx @@ -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 }) => ( + {children} + ); + return { Wrapper, client }; +} + +describe('useCrmList', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('returns an array when API returns a plain array', async () => { + (apiInstance.get as ReturnType).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).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).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) + .mockResolvedValueOnce({ data: [{ id: 1 }] }) + .mockResolvedValueOnce({ data: [{ id: 1 }, { id: 2 }] }); + (apiInstance.post as ReturnType).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).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')); + }); +}); diff --git a/frontend/src/shared/api/queryHooks.ts b/frontend/src/shared/api/queryHooks.ts new file mode 100644 index 0000000..ca402d1 --- /dev/null +++ b/frontend/src/shared/api/queryHooks.ts @@ -0,0 +1,74 @@ +/** + * Тонкие обёртки над 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[] | { data?: T[]; items?: T[] }; + +function normalizeList(payload: ListResponse | 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( + resource: string, + options?: Omit, 'queryKey' | 'queryFn'>, +) { + return useQuery({ + queryKey: [resource], + queryFn: async () => { + const res = await apiInstance.get(`/${resource}`); + return normalizeList(res.data); + }, + ...options, + }); +} + +type Method = 'post' | 'put' | 'patch' | 'delete'; + +interface CrmMutationParams { + resource: string; + method: Method; + url?: (vars: TVars) => string; +} + +export function useCrmMutation( + { resource, method, url }: CrmMutationParams, + options?: UseMutationOptions, +) { + const qc = useQueryClient(); + return useMutation({ + 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; + }, + onSuccess: (...args) => { + qc.invalidateQueries({ queryKey: [resource] }); + options?.onSuccess?.(...args); + }, + ...options, + }); +} From d9f69387f622293ec744376a92320be07caa2192 Mon Sep 17 00:00:00 2001 From: rodielavender Date: Wed, 13 May 2026 23:49:41 +0000 Subject: [PATCH 2/3] fix(crm): \u0441\u043a\u0435\u043b\u0435\u0442\u043e\u043d \u0442\u043e\u043b\u044c\u043a\u043e \u043f\u0440\u0438 \u043f\u0435\u0440\u0432\u043e\u0439 \u043f\u043e\u0434\u0433\u0440\u0443\u0437\u043a\u0435 (isPending), \u043d\u0435 \u043d\u0430 background refetch --- frontend/src/components/Applications.tsx | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/frontend/src/components/Applications.tsx b/frontend/src/components/Applications.tsx index 5766ede..569c4bd 100644 --- a/frontend/src/components/Applications.tsx +++ b/frontend/src/components/Applications.tsx @@ -36,7 +36,8 @@ const Applications: React.FC = () => { const [modalOpen, setModalOpen] = useState(false); const [form, setForm] = useState({ client_name: '', topic: '', lawyer_name: '', comment: '' }); - const { data = [], isFetching: loading, refetch } = useCrmList('applications'); + const { data = [], isPending, isFetching, refetch } = useCrmList('applications'); + const loading = isFetching; const createMut = useCrmMutation( { resource: 'applications', method: 'post' }, @@ -94,7 +95,7 @@ const Applications: React.FC = () => { - {loading && data.length === 0 ? ( + {isPending && data.length === 0 ? ( ) : ( From 0a5cb5a6cd780d625cad2c276251ee4d72cfa4ce Mon Sep 17 00:00:00 2001 From: rodielavender Date: Wed, 13 May 2026 23:53:19 +0000 Subject: [PATCH 3/3] fix(query-hooks): \u043f\u043e\u0440\u044f\u0434\u043e\u043a spread options \u0437\u0430\u0442\u0438\u0440\u0430\u043b \u0438\u043d\u0432\u0430\u043b\u0438\u0434\u0430\u0446\u0438\u044e \u043a\u044d\u0448\u0430 \u0432 onSuccess --- frontend/src/shared/api/queryHooks.ts | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/frontend/src/shared/api/queryHooks.ts b/frontend/src/shared/api/queryHooks.ts index ca402d1..8a84848 100644 --- a/frontend/src/shared/api/queryHooks.ts +++ b/frontend/src/shared/api/queryHooks.ts @@ -51,6 +51,10 @@ export function useCrmMutation( options?: UseMutationOptions, ) { const qc = useQueryClient(); + // Важно: сначала разлапачиваем пользовательские options, а затем + // навешиваем свой onSuccess поверх — иначе options?.onSuccess затирает + // нашу инвалидацию кэша и таблица не обновляется после мутации. + const { onSuccess: userOnSuccess, ...rest } = options || {}; return useMutation({ mutationFn: async (vars: TVars) => { const path = url ? url(vars) : `/${resource}`; @@ -65,10 +69,10 @@ export function useCrmMutation( : await apiInstance.patch(path, cfg); return res.data as TResp; }, - onSuccess: (...args) => { + ...rest, + onSuccess: (data, variables, context) => { qc.invalidateQueries({ queryKey: [resource] }); - options?.onSuccess?.(...args); + userOnSuccess?.(data, variables, context); }, - ...options, }); }