Compare commits
No commits in common. "5a5c308e45b38cd1cf9cbaf05d84b11163eb8c4c" and "2c1a8ea127bd742f40ea70cb0883869d6775eae8" have entirely different histories.
5a5c308e45
...
2c1a8ea127
6
.env
6
.env
@ -1,6 +0,0 @@
|
||||
POSTGRES_USER='root'
|
||||
POSTGRES_PASSWORD='123456'
|
||||
POSTGRES_HOST='localhost'
|
||||
POSTGRES_PORT='5433'
|
||||
POSTGRES_DATABASE='investments'
|
||||
POSTGRES_URI="postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@${POSTGRES_HOST}:${POSTGRES_PORT}/${POSTGRES_DATABASE}"
|
||||
@ -1 +0,0 @@
|
||||
export default { extends: ['@commitlint/config-conventional'] };
|
||||
@ -1,3 +0,0 @@
|
||||
BACKEND_BASE_URL="http://localhost:3000"
|
||||
PUBLIC_API_BASE_URL="/api"
|
||||
PUBLIC_ENABLE_MOCKING=false
|
||||
@ -1,2 +0,0 @@
|
||||
PUBLIC_API_BASE_URL="/"
|
||||
PUBLIC_ENABLE_MOCKING=true
|
||||
2
frontend/.gitignore
vendored
2
frontend/.gitignore
vendored
@ -14,5 +14,3 @@ dist/
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
|
||||
coverage
|
||||
|
||||
@ -1,23 +0,0 @@
|
||||
export const bankCardList = [
|
||||
{
|
||||
balance: 0,
|
||||
createdAt: '2025-09-09T01:58:06.993Z',
|
||||
id: 'e2a7beee-2a1f-4881-97c5-0a7793c78adf',
|
||||
name: 'Сбербанк',
|
||||
updatedAt: '2025-09-09T01:58:06.993Z',
|
||||
},
|
||||
{
|
||||
balance: 100,
|
||||
createdAt: '2025-09-09T01:59:47.698Z',
|
||||
id: '4360f93c-0e8e-4285-9b0d-ba5701f43013',
|
||||
name: 'Тинькофф !',
|
||||
updatedAt: '2025-09-11T02:33:10.293Z',
|
||||
},
|
||||
{
|
||||
balance: 200,
|
||||
createdAt: '2025-09-10T02:59:43.228Z',
|
||||
id: 'b9f8eef6-5e8a-4e64-9e10-d0d6fabf05dc',
|
||||
name: 'Альфа-Банк',
|
||||
updatedAt: '2025-09-11T02:33:07.601Z',
|
||||
},
|
||||
];
|
||||
@ -1,18 +0,0 @@
|
||||
export const cashList = [
|
||||
{
|
||||
balance: 41300,
|
||||
createdAt: '2025-09-12T03:04:42.222Z',
|
||||
id: '07d99f18-0618-444f-af5a-c5084d79bc31',
|
||||
name: 'Аренда квартиры',
|
||||
updatedAt: '2025-09-12T04:06:48.224Z',
|
||||
userId: 'a94c46ad-64bb-4d6a-ba0f-85a466f57537',
|
||||
},
|
||||
{
|
||||
balance: 19000,
|
||||
createdAt: '2025-09-12T04:07:01.713Z',
|
||||
id: 'f2d52442-c260-470c-9111-0da736f4daaf',
|
||||
name: 'Свободные',
|
||||
updatedAt: '2025-09-18T02:38:22.869Z',
|
||||
userId: 'a94c46ad-64bb-4d6a-ba0f-85a466f57537',
|
||||
},
|
||||
];
|
||||
@ -1,77 +0,0 @@
|
||||
export const items1 = [
|
||||
{
|
||||
amount: 500,
|
||||
count: 1,
|
||||
createdAt: '2024-04-01T12:00:00Z',
|
||||
currency: 'RUB',
|
||||
date: '2024-04-01',
|
||||
description: 'Ежемесячная плата за интернет',
|
||||
expenseListId: '1',
|
||||
id: '1',
|
||||
payment: null,
|
||||
title: 'Счет за интернет',
|
||||
updatedAt: '2024-04-01T12:00:00Z',
|
||||
},
|
||||
{
|
||||
amount: 3000,
|
||||
count: 1,
|
||||
createdAt: '2024-04-02T12:00:00Z',
|
||||
currency: 'RUB',
|
||||
date: '2024-04-02',
|
||||
description: 'Продукты на неделю',
|
||||
expenseListId: '1',
|
||||
id: '2',
|
||||
payment: null,
|
||||
title: 'Покупка продуктов',
|
||||
updatedAt: '2024-04-02T12:00:00Z',
|
||||
},
|
||||
];
|
||||
|
||||
export const items2 = [];
|
||||
|
||||
export const items3 = null;
|
||||
|
||||
export const items4 = [
|
||||
{
|
||||
amount: 1500,
|
||||
count: null,
|
||||
createdAt: '2024-04-03T12:00:00Z',
|
||||
currency: 'RUB',
|
||||
date: '2024-04-03',
|
||||
description: null,
|
||||
expenseListId: '4',
|
||||
id: '3',
|
||||
payment: null,
|
||||
title: 'Бензин',
|
||||
updatedAt: '2024-04-03T12:00:00Z',
|
||||
},
|
||||
];
|
||||
|
||||
export const items5 = [
|
||||
{
|
||||
amount: 50000,
|
||||
count: 1,
|
||||
createdAt: '2024-04-04T12:00:00Z',
|
||||
currency: 'RUB',
|
||||
date: '2024-04-04',
|
||||
description: 'Покупка нового ноутбука',
|
||||
expenseListId: '5',
|
||||
id: '4',
|
||||
payment: null,
|
||||
title: 'Ноутбук',
|
||||
updatedAt: '2024-04-04T12:00:00Z',
|
||||
},
|
||||
{
|
||||
amount: 3000,
|
||||
count: 1,
|
||||
createdAt: '2024-04-05T12:00:00Z',
|
||||
currency: 'RUB',
|
||||
date: '2024-04-05',
|
||||
description: 'Механическая мышка',
|
||||
expenseListId: '5',
|
||||
id: '5',
|
||||
payment: null,
|
||||
title: 'Мышка',
|
||||
updatedAt: '2024-04-05T12:00:00Z',
|
||||
},
|
||||
];
|
||||
@ -1,47 +0,0 @@
|
||||
import {
|
||||
items1,
|
||||
items2,
|
||||
items3,
|
||||
items4,
|
||||
items5,
|
||||
} from './items';
|
||||
|
||||
export const list1 = {
|
||||
createdAt: '2024-03-28T10:00:00Z',
|
||||
id: '1',
|
||||
items: items1,
|
||||
title: 'Расходы на быт',
|
||||
updatedAt: '2024-04-02T12:00:00Z',
|
||||
};
|
||||
|
||||
export const list2 = {
|
||||
createdAt: '2024-04-01T10:00:00Z',
|
||||
id: '2',
|
||||
items: items2,
|
||||
title: 'Дополнительные расходы',
|
||||
updatedAt: '2024-04-01T10:00:00Z',
|
||||
};
|
||||
|
||||
export const list3 = {
|
||||
createdAt: '2024-04-01T11:00:00Z',
|
||||
id: '3',
|
||||
items: items3,
|
||||
title: 'Кредиты',
|
||||
updatedAt: '2024-04-01T11:00:00Z',
|
||||
};
|
||||
|
||||
export const list4 = {
|
||||
createdAt: '2024-04-03T10:00:00Z',
|
||||
id: '4',
|
||||
items: items4,
|
||||
title: 'Автомобиль',
|
||||
updatedAt: '2024-04-03T10:00:00Z',
|
||||
};
|
||||
|
||||
export const list5 = {
|
||||
createdAt: '2024-04-04T10:00:00Z',
|
||||
id: '5',
|
||||
items: items5,
|
||||
title: 'Техника',
|
||||
updatedAt: '2024-04-05T10:00:00Z',
|
||||
};
|
||||
@ -1,2 +0,0 @@
|
||||
export { bankCardList } from './bankCards/list';
|
||||
export { cashList } from './cash/list';
|
||||
@ -1,9 +0,0 @@
|
||||
import { http, HttpResponse } from 'msw';
|
||||
|
||||
import type { paths } from '@/shared/api/schema';
|
||||
|
||||
type LoginResponse = paths['/auth/login']['post']['responses']['200']['content']['application/json'];
|
||||
export const loginHandler = http.post<never, never, LoginResponse>(
|
||||
'/auth/login',
|
||||
() => HttpResponse.json({ accessToken: '123' }),
|
||||
);
|
||||
@ -1,11 +0,0 @@
|
||||
import { http, HttpResponse } from 'msw';
|
||||
|
||||
import type { paths } from '@/shared/api/schema';
|
||||
|
||||
import { bankCardList } from '../../../data/bankCards/list';
|
||||
|
||||
type BankCardsResponse = paths['/bank-card']['get']['responses']['200']['content']['application/json'];
|
||||
export const getBankCardList = http.get<never, never, BankCardsResponse>(
|
||||
'/bank-card',
|
||||
() => HttpResponse.json(bankCardList),
|
||||
);
|
||||
@ -1,11 +0,0 @@
|
||||
import { http, HttpResponse } from 'msw';
|
||||
|
||||
import type { paths } from '@/shared/api/schema';
|
||||
|
||||
import { cashList } from '../../../data/cash/list';
|
||||
|
||||
type CashResponse = paths['/cash']['get']['responses']['200']['content']['application/json'];
|
||||
export const getCashList = http.get<never, never, CashResponse>(
|
||||
'/cash',
|
||||
() => HttpResponse.json(cashList),
|
||||
);
|
||||
@ -1,12 +0,0 @@
|
||||
import { http, HttpResponse } from 'msw';
|
||||
|
||||
import type { paths } from '@/shared/api/schema';
|
||||
|
||||
type ExpensesListItemsResponse = paths['/expenses/list/{id}']['get']['responses']['200']['content']['application/json'];
|
||||
export const getExpenseItemListById = (id: string) => http.get<never, never, ExpensesListItemsResponse>(
|
||||
`/expenses/list/${id}`,
|
||||
|
||||
// @ts-ignore
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
|
||||
async () => HttpResponse.json((await import('@mocks/browser/data/expenses/lists'))[`list${id}`]),
|
||||
);
|
||||
@ -1,24 +0,0 @@
|
||||
import {
|
||||
list1,
|
||||
list2,
|
||||
list3,
|
||||
list4,
|
||||
list5,
|
||||
} from '@mocks/browser/data/expenses/lists';
|
||||
import { http, HttpResponse } from 'msw';
|
||||
|
||||
import type { paths } from '@/shared/api/schema';
|
||||
|
||||
const expenseLists = [
|
||||
list1,
|
||||
list2,
|
||||
list3,
|
||||
list4,
|
||||
list5,
|
||||
];
|
||||
|
||||
type ExpensesListResponse = paths['/expenses/list']['get']['responses']['200']['content']['application/json'];
|
||||
export const getExpenseList = http.get<never, never, ExpensesListResponse>(
|
||||
'/expenses/list',
|
||||
() => HttpResponse.json(expenseLists),
|
||||
);
|
||||
@ -1,5 +0,0 @@
|
||||
export { loginHandler } from './auth/login/post';
|
||||
export { getBankCardList } from './bank-cards/list/get';
|
||||
export { getCashList } from './cash/list/get';
|
||||
export { getExpenseItemListById } from './expense/list/:id/get';
|
||||
export { getExpenseList } from './expense/list/get';
|
||||
@ -1,17 +0,0 @@
|
||||
import { setupWorker } from 'msw/browser';
|
||||
|
||||
import {
|
||||
getExpenseItemListById,
|
||||
getExpenseList,
|
||||
loginHandler,
|
||||
} from './handlers';
|
||||
|
||||
export const worker = setupWorker(
|
||||
loginHandler,
|
||||
getExpenseList,
|
||||
getExpenseItemListById('1'),
|
||||
getExpenseItemListById('2'),
|
||||
getExpenseItemListById('3'),
|
||||
getExpenseItemListById('4'),
|
||||
getExpenseItemListById('5'),
|
||||
);
|
||||
@ -1 +0,0 @@
|
||||
module.exports = 'test-file-stub';
|
||||
@ -1,3 +0,0 @@
|
||||
export { selectOption } from './selectOption';
|
||||
export { typeDateField } from './typeDateField';
|
||||
export { typeTextField } from './typeTextField';
|
||||
@ -1,16 +0,0 @@
|
||||
import { screen, within } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
|
||||
export const selectOption = async (selectElement: HTMLElement | null, value: string) => {
|
||||
if (!selectElement) {
|
||||
throw new Error('Не найден элемент выпадающего списка');
|
||||
}
|
||||
|
||||
const bankcardDropdown = within(selectElement).getByRole('combobox');
|
||||
await userEvent.click(bankcardDropdown);
|
||||
const option = screen.getAllByRole('option').find((el) => el.dataset.value === value);
|
||||
if (!option) {
|
||||
throw new Error(`Не найдена опция с value:${value}`);
|
||||
}
|
||||
await userEvent.click(option);
|
||||
};
|
||||
@ -1,17 +0,0 @@
|
||||
import userEvent from '@testing-library/user-event';
|
||||
|
||||
export const typeDateField = async (dateElement: HTMLElement | null, value?: null | string) => {
|
||||
if (!dateElement) {
|
||||
throw new Error('Не найдено поле ввода');
|
||||
}
|
||||
|
||||
if (value === null) {
|
||||
await userEvent.clear(dateElement);
|
||||
}
|
||||
|
||||
if (value) {
|
||||
await userEvent.click(dateElement);
|
||||
await userEvent.clear(dateElement);
|
||||
await userEvent.type(dateElement, value);
|
||||
}
|
||||
};
|
||||
@ -1,16 +0,0 @@
|
||||
import userEvent from '@testing-library/user-event';
|
||||
|
||||
export const typeTextField = async (inputElement: HTMLElement | null, value?: null | string) => {
|
||||
if (!inputElement) {
|
||||
throw new Error('Не найдено поле ввода');
|
||||
}
|
||||
|
||||
if (value === null) {
|
||||
await userEvent.clear(inputElement);
|
||||
}
|
||||
|
||||
if (value) {
|
||||
await userEvent.clear(inputElement);
|
||||
await userEvent.type(inputElement, value);
|
||||
}
|
||||
};
|
||||
@ -1,3 +0,0 @@
|
||||
import { setupServer } from 'msw/node';
|
||||
|
||||
export const server = setupServer();
|
||||
@ -1,17 +0,0 @@
|
||||
import type { PropsWithChildren } from 'react';
|
||||
|
||||
import { createLocalizationWrapper } from './LocalizationWrapper';
|
||||
import { createReactQueryWrapper } from './ReactQueryWrapper';
|
||||
|
||||
export const createAppWrapper = () => ({ children }: PropsWithChildren) => {
|
||||
const ReactQueryWrapper = createReactQueryWrapper();
|
||||
const LocalizationWrapper = createLocalizationWrapper();
|
||||
|
||||
return (
|
||||
<ReactQueryWrapper>
|
||||
<LocalizationWrapper>
|
||||
{children}
|
||||
</LocalizationWrapper>
|
||||
</ReactQueryWrapper>
|
||||
);
|
||||
};
|
||||
@ -1,11 +0,0 @@
|
||||
import type { PropsWithChildren } from 'react';
|
||||
|
||||
import { LocalizationProvider } from '@mui/x-date-pickers';
|
||||
import { AdapterDateFns } from '@mui/x-date-pickers/AdapterDateFns';
|
||||
import { ru } from 'date-fns/locale/ru';
|
||||
|
||||
export const createLocalizationWrapper = () => ({ children }: PropsWithChildren) => (
|
||||
<LocalizationProvider adapterLocale={ru} dateAdapter={AdapterDateFns}>
|
||||
{children}
|
||||
</LocalizationProvider>
|
||||
);
|
||||
@ -1,19 +0,0 @@
|
||||
import type { PropsWithChildren } from 'react';
|
||||
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
|
||||
export const createReactQueryWrapper = () => {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
retry: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return ({ children }: PropsWithChildren) => (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
{children}
|
||||
</QueryClientProvider>
|
||||
);
|
||||
};
|
||||
@ -1,3 +0,0 @@
|
||||
export { createAppWrapper } from './AppWrapper';
|
||||
export { createLocalizationWrapper } from './LocalizationWrapper';
|
||||
export { createReactQueryWrapper } from './ReactQueryWrapper';
|
||||
@ -1,30 +0,0 @@
|
||||
// eslint-disable-next-line import/no-import-module-exports
|
||||
import JSDOMEnvironment from 'jest-environment-jsdom';
|
||||
|
||||
class FixedJSDOMEnvironment extends JSDOMEnvironment {
|
||||
constructor(...args: unknown[]) {
|
||||
// @ts-ignore
|
||||
super(...args);
|
||||
|
||||
this.global.TextDecoder = TextDecoder;
|
||||
this.global.TextEncoder = TextEncoder;
|
||||
this.global.TextDecoderStream = TextDecoderStream;
|
||||
this.global.TextEncoderStream = TextEncoderStream;
|
||||
this.global.ReadableStream = ReadableStream;
|
||||
|
||||
this.global.Blob = Blob;
|
||||
this.global.Headers = Headers;
|
||||
// this.global.FormData = FormData;
|
||||
this.global.Request = Request;
|
||||
this.global.Response = Response;
|
||||
this.global.fetch = fetch;
|
||||
this.global.structuredClone = structuredClone;
|
||||
this.global.URL = URL;
|
||||
this.global.URLSearchParams = URLSearchParams;
|
||||
|
||||
this.global.BroadcastChannel = BroadcastChannel;
|
||||
this.global.TransformStream = TransformStream;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = FixedJSDOMEnvironment;
|
||||
@ -1 +0,0 @@
|
||||
process.env.TZ = 'Europe/Moscow';
|
||||
@ -1,14 +0,0 @@
|
||||
import '@testing-library/jest-dom';
|
||||
import { server } from '@mocks/jest/server';
|
||||
|
||||
import './setupEnv';
|
||||
|
||||
beforeAll(() => {
|
||||
server.listen();
|
||||
});
|
||||
afterEach(() => {
|
||||
server.resetHandlers();
|
||||
});
|
||||
afterAll(() => {
|
||||
server.close();
|
||||
});
|
||||
@ -1,41 +1,84 @@
|
||||
import pluginQuery from '@tanstack/eslint-plugin-query';
|
||||
// @ts-expect-error fix package
|
||||
import config from 'eslint-config-ksv741';
|
||||
import perfectionist from 'eslint-plugin-perfectionist';
|
||||
|
||||
// eslint-disable-next-line import/no-anonymous-default-export
|
||||
export default [
|
||||
...pluginQuery.configs['flat/recommended'],
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
||||
...config,
|
||||
{
|
||||
rules: {
|
||||
'@stylistic/jsx-sort-props': 'off',
|
||||
'@typescript-eslint/no-floating-promises': 'off',
|
||||
'@typescript-eslint/sort-type-constituents': 'off',
|
||||
'@typescript-eslint/strict-boolean-expressions': 'off',
|
||||
'func-style': 'off',
|
||||
'import/no-extraneous-dependencies': ['error', {
|
||||
devDependencies: [
|
||||
'eslint.config.ts',
|
||||
'rsbuild.config.ts',
|
||||
'configs/**/*',
|
||||
'**/*.test.js',
|
||||
'**/*.test.jsx',
|
||||
'**/*.test.ts',
|
||||
'**/*.test.tsx',
|
||||
'**/__tests__/**',
|
||||
'**/tests/**',
|
||||
'__mocks__/**',
|
||||
],
|
||||
devDependencies: ['eslint.config.ts', 'rsbuild.config.ts'],
|
||||
}],
|
||||
'import/order': 'off',
|
||||
'jsx-a11y/click-events-have-key-events': 'off',
|
||||
'jsx-a11y/no-noninteractive-element-interactions': 'off',
|
||||
'jsx-a11y/no-static-element-interactions': 'off',
|
||||
'import/no-anonymous-default-export': 'off',
|
||||
'import/order': [
|
||||
'error', {
|
||||
pathGroups: [
|
||||
{
|
||||
pattern: '__spec__/**',
|
||||
group: 'builtin',
|
||||
position: 'before',
|
||||
},
|
||||
{
|
||||
pattern: 'apps/**',
|
||||
group: 'internal',
|
||||
position: 'after',
|
||||
},
|
||||
{
|
||||
pattern: 'pages/**',
|
||||
group: 'internal',
|
||||
position: 'after',
|
||||
},
|
||||
{
|
||||
pattern: 'widgets/**',
|
||||
group: 'internal',
|
||||
position: 'after',
|
||||
},
|
||||
{
|
||||
pattern: 'features/**',
|
||||
group: 'internal',
|
||||
position: 'after',
|
||||
},
|
||||
{
|
||||
pattern: 'entities/**',
|
||||
group: 'internal',
|
||||
position: 'after',
|
||||
},
|
||||
{
|
||||
pattern: 'shared/**',
|
||||
group: 'internal',
|
||||
position: 'after',
|
||||
},
|
||||
],
|
||||
distinctGroup: true,
|
||||
groups: [
|
||||
'builtin',
|
||||
'external',
|
||||
'internal',
|
||||
'parent',
|
||||
'sibling',
|
||||
'object',
|
||||
'index',
|
||||
'type',
|
||||
],
|
||||
'newlines-between': 'always',
|
||||
alphabetize: {
|
||||
order: 'asc',
|
||||
caseInsensitive: true,
|
||||
},
|
||||
},
|
||||
],
|
||||
'@typescript-eslint/strict-boolean-expressions': 'off',
|
||||
'@typescript-eslint/no-floating-promises': 'off',
|
||||
'@typescript-eslint/consistent-return': 'off',
|
||||
'func-style': 'off',
|
||||
'@typescript-eslint/no-confusing-void-expression': 'off',
|
||||
'@typescript-eslint/await-thenable': 'off',
|
||||
'@typescript-eslint/no-misused-promises': 'off',
|
||||
'@typescript-eslint/prefer-nullish-coalescing': 'off',
|
||||
},
|
||||
},
|
||||
perfectionist.configs['recommended-natural'],
|
||||
{
|
||||
ignores: ['./src/shared/api/schema.ts'],
|
||||
},
|
||||
|
||||
@ -1,25 +0,0 @@
|
||||
import type { Config } from 'jest';
|
||||
|
||||
export default {
|
||||
moduleNameMapper: {
|
||||
'@/(.*)': '<rootDir>/src/$1',
|
||||
'@mocks/(.*)': '<rootDir>/__mocks__/$1',
|
||||
'@tests/(.*)': '<rootDir>/tests/$1',
|
||||
'\\.(css|scss|sass|less)$': 'identity-obj-proxy',
|
||||
'\\.(jpg|jpeg|png|gif|webp|svg)$': '<rootDir>/__mocks__/jest/fileMock.js',
|
||||
},
|
||||
setupFilesAfterEnv: ['<rootDir>/configs/jest/setupTests.ts'],
|
||||
testEnvironment: '<rootDir>/configs/jest/jest-fixed-jsdom.ts',
|
||||
transform: {
|
||||
'^.+\\.(t|j)sx?$': ['@swc/jest', {
|
||||
jsc: {
|
||||
transform: {
|
||||
react: {
|
||||
runtime: 'automatic',
|
||||
},
|
||||
},
|
||||
},
|
||||
}],
|
||||
},
|
||||
transformIgnorePatterns: ['node_modules/(?!(until-async)/)'],
|
||||
} as Config;
|
||||
8762
frontend/package-lock.json
generated
8762
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -6,57 +6,30 @@
|
||||
"scripts": {
|
||||
"build": "rsbuild build",
|
||||
"dev": "rsbuild dev",
|
||||
"dev:mock": "rsbuild dev --env-mode mock",
|
||||
"preview": "rsbuild preview",
|
||||
"lint": "eslint --concurrency=auto",
|
||||
"test:unit": "jest --verbose --silent",
|
||||
"lint": "eslint",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"openapi:gen": "openapi-typescript http://localhost:3000/docs/swagger.json -o src/shared/api/schema.ts"
|
||||
"openapi:gen": "npx openapi-typescript http://localhost:3000/docs/swagger.json -o src/shared/api/schema.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@emotion/react": "11.14.0",
|
||||
"@emotion/styled": "11.14.1",
|
||||
"@fontsource/roboto": "5.2.8",
|
||||
"@mui/icons-material": "7.3.4",
|
||||
"@mui/material": "7.3.4",
|
||||
"@mui/x-date-pickers": "8.12.0",
|
||||
"@tanstack/react-query": "5.90.2",
|
||||
"axios": "1.12.2",
|
||||
"date-fns": "4.1.0",
|
||||
"date-fns-tz": "3.2.0",
|
||||
"react": "19.2.0",
|
||||
"react-dom": "19.2.0",
|
||||
"react-router": "7.9.4"
|
||||
"@tanstack/react-query": "5.85.5",
|
||||
"axios": "1.11.0",
|
||||
"react": "19.1.1",
|
||||
"react-dom": "19.1.1",
|
||||
"react-router": "7.8.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@rsbuild/core": "1.5.14",
|
||||
"@rsbuild/core": "1.4.15",
|
||||
"@rsbuild/plugin-eslint": "1.1.2",
|
||||
"@rsbuild/plugin-react": "1.4.1",
|
||||
"@rsbuild/plugin-react": "1.3.5",
|
||||
"@rsbuild/plugin-type-check": "1.2.4",
|
||||
"@swc/core": "1.13.5",
|
||||
"@swc/jest": "0.2.39",
|
||||
"@tanstack/eslint-plugin-query": "5.91.0",
|
||||
"@testing-library/dom": "10.4.1",
|
||||
"@testing-library/jest-dom": "6.9.1",
|
||||
"@testing-library/react": "16.3.0",
|
||||
"@testing-library/user-event": "14.6.1",
|
||||
"@types/jest": "30.0.0",
|
||||
"@types/react": "19.2.2",
|
||||
"@types/react-dom": "19.2.1",
|
||||
"eslint": "9.37.0",
|
||||
"eslint-config-ksv741": "0.3.0",
|
||||
"eslint-plugin-perfectionist": "4.15.1",
|
||||
"identity-obj-proxy": "3.0.0",
|
||||
"jest": "30.2.0",
|
||||
"jest-environment-jsdom": "30.2.0",
|
||||
"msw": "2.11.3",
|
||||
"@tanstack/eslint-plugin-query": "5.83.1",
|
||||
"@types/react": "19.1.9",
|
||||
"@types/react-dom": "19.1.7",
|
||||
"@typescript-eslint/parser": "8.39.0",
|
||||
"eslint": "9.33.0",
|
||||
"eslint-config-ksv741": "0.2.0",
|
||||
"openapi-typescript": "7.9.1",
|
||||
"ts-node": "10.9.2",
|
||||
"typescript": "5.9.3"
|
||||
},
|
||||
"msw": {
|
||||
"workerDirectory": [
|
||||
"public"
|
||||
]
|
||||
"typescript": "5.9.2"
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,348 +0,0 @@
|
||||
/* eslint-disable */
|
||||
/* tslint:disable */
|
||||
|
||||
/**
|
||||
* Mock Service Worker.
|
||||
* @see https://github.com/mswjs/msw
|
||||
* - Please do NOT modify this file.
|
||||
*/
|
||||
|
||||
const PACKAGE_VERSION = '2.11.3'
|
||||
const INTEGRITY_CHECKSUM = '4db4a41e972cec1b64cc569c66952d82'
|
||||
const IS_MOCKED_RESPONSE = Symbol('isMockedResponse')
|
||||
const activeClientIds = new Set()
|
||||
|
||||
addEventListener('install', function () {
|
||||
self.skipWaiting()
|
||||
})
|
||||
|
||||
addEventListener('activate', function (event) {
|
||||
event.waitUntil(self.clients.claim())
|
||||
})
|
||||
|
||||
addEventListener('message', async function (event) {
|
||||
const clientId = Reflect.get(event.source || {}, 'id')
|
||||
|
||||
if (!clientId || !self.clients) {
|
||||
return
|
||||
}
|
||||
|
||||
const client = await self.clients.get(clientId)
|
||||
|
||||
if (!client) {
|
||||
return
|
||||
}
|
||||
|
||||
const allClients = await self.clients.matchAll({
|
||||
type: 'window',
|
||||
})
|
||||
|
||||
switch (event.data) {
|
||||
case 'KEEPALIVE_REQUEST': {
|
||||
sendToClient(client, {
|
||||
type: 'KEEPALIVE_RESPONSE',
|
||||
})
|
||||
break
|
||||
}
|
||||
|
||||
case 'INTEGRITY_CHECK_REQUEST': {
|
||||
sendToClient(client, {
|
||||
type: 'INTEGRITY_CHECK_RESPONSE',
|
||||
payload: {
|
||||
packageVersion: PACKAGE_VERSION,
|
||||
checksum: INTEGRITY_CHECKSUM,
|
||||
},
|
||||
})
|
||||
break
|
||||
}
|
||||
|
||||
case 'MOCK_ACTIVATE': {
|
||||
activeClientIds.add(clientId)
|
||||
|
||||
sendToClient(client, {
|
||||
type: 'MOCKING_ENABLED',
|
||||
payload: {
|
||||
client: {
|
||||
id: client.id,
|
||||
frameType: client.frameType,
|
||||
},
|
||||
},
|
||||
})
|
||||
break
|
||||
}
|
||||
|
||||
case 'CLIENT_CLOSED': {
|
||||
activeClientIds.delete(clientId)
|
||||
|
||||
const remainingClients = allClients.filter((client) => {
|
||||
return client.id !== clientId
|
||||
})
|
||||
|
||||
// Unregister itself when there are no more clients
|
||||
if (remainingClients.length === 0) {
|
||||
self.registration.unregister()
|
||||
}
|
||||
|
||||
break
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
addEventListener('fetch', function (event) {
|
||||
const requestInterceptedAt = Date.now()
|
||||
|
||||
// Bypass navigation requests.
|
||||
if (event.request.mode === 'navigate') {
|
||||
return
|
||||
}
|
||||
|
||||
// Opening the DevTools triggers the "only-if-cached" request
|
||||
// that cannot be handled by the worker. Bypass such requests.
|
||||
if (
|
||||
event.request.cache === 'only-if-cached' &&
|
||||
event.request.mode !== 'same-origin'
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
// Bypass all requests when there are no active clients.
|
||||
// Prevents the self-unregistered worked from handling requests
|
||||
// after it's been terminated (still remains active until the next reload).
|
||||
if (activeClientIds.size === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
const requestId = crypto.randomUUID()
|
||||
event.respondWith(handleRequest(event, requestId, requestInterceptedAt))
|
||||
})
|
||||
|
||||
/**
|
||||
* @param {FetchEvent} event
|
||||
* @param {string} requestId
|
||||
* @param {number} requestInterceptedAt
|
||||
*/
|
||||
async function handleRequest(event, requestId, requestInterceptedAt) {
|
||||
const client = await resolveMainClient(event)
|
||||
const requestCloneForEvents = event.request.clone()
|
||||
const response = await getResponse(
|
||||
event,
|
||||
client,
|
||||
requestId,
|
||||
requestInterceptedAt,
|
||||
)
|
||||
|
||||
// Send back the response clone for the "response:*" life-cycle events.
|
||||
// Ensure MSW is active and ready to handle the message, otherwise
|
||||
// this message will pend indefinitely.
|
||||
if (client && activeClientIds.has(client.id)) {
|
||||
const serializedRequest = await serializeRequest(requestCloneForEvents)
|
||||
|
||||
// Clone the response so both the client and the library could consume it.
|
||||
const responseClone = response.clone()
|
||||
|
||||
sendToClient(
|
||||
client,
|
||||
{
|
||||
type: 'RESPONSE',
|
||||
payload: {
|
||||
isMockedResponse: IS_MOCKED_RESPONSE in response,
|
||||
request: {
|
||||
id: requestId,
|
||||
...serializedRequest,
|
||||
},
|
||||
response: {
|
||||
type: responseClone.type,
|
||||
status: responseClone.status,
|
||||
statusText: responseClone.statusText,
|
||||
headers: Object.fromEntries(responseClone.headers.entries()),
|
||||
body: responseClone.body,
|
||||
},
|
||||
},
|
||||
},
|
||||
responseClone.body ? [serializedRequest.body, responseClone.body] : [],
|
||||
)
|
||||
}
|
||||
|
||||
return response
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the main client for the given event.
|
||||
* Client that issues a request doesn't necessarily equal the client
|
||||
* that registered the worker. It's with the latter the worker should
|
||||
* communicate with during the response resolving phase.
|
||||
* @param {FetchEvent} event
|
||||
* @returns {Promise<Client | undefined>}
|
||||
*/
|
||||
async function resolveMainClient(event) {
|
||||
const client = await self.clients.get(event.clientId)
|
||||
|
||||
if (activeClientIds.has(event.clientId)) {
|
||||
return client
|
||||
}
|
||||
|
||||
if (client?.frameType === 'top-level') {
|
||||
return client
|
||||
}
|
||||
|
||||
const allClients = await self.clients.matchAll({
|
||||
type: 'window',
|
||||
})
|
||||
|
||||
return allClients
|
||||
.filter((client) => {
|
||||
// Get only those clients that are currently visible.
|
||||
return client.visibilityState === 'visible'
|
||||
})
|
||||
.find((client) => {
|
||||
// Find the client ID that's recorded in the
|
||||
// set of clients that have registered the worker.
|
||||
return activeClientIds.has(client.id)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {FetchEvent} event
|
||||
* @param {Client | undefined} client
|
||||
* @param {string} requestId
|
||||
* @returns {Promise<Response>}
|
||||
*/
|
||||
async function getResponse(event, client, requestId, requestInterceptedAt) {
|
||||
// Clone the request because it might've been already used
|
||||
// (i.e. its body has been read and sent to the client).
|
||||
const requestClone = event.request.clone()
|
||||
|
||||
function passthrough() {
|
||||
// Cast the request headers to a new Headers instance
|
||||
// so the headers can be manipulated with.
|
||||
const headers = new Headers(requestClone.headers)
|
||||
|
||||
// Remove the "accept" header value that marked this request as passthrough.
|
||||
// This prevents request alteration and also keeps it compliant with the
|
||||
// user-defined CORS policies.
|
||||
const acceptHeader = headers.get('accept')
|
||||
if (acceptHeader) {
|
||||
const values = acceptHeader.split(',').map((value) => value.trim())
|
||||
const filteredValues = values.filter(
|
||||
(value) => value !== 'msw/passthrough',
|
||||
)
|
||||
|
||||
if (filteredValues.length > 0) {
|
||||
headers.set('accept', filteredValues.join(', '))
|
||||
} else {
|
||||
headers.delete('accept')
|
||||
}
|
||||
}
|
||||
|
||||
return fetch(requestClone, { headers })
|
||||
}
|
||||
|
||||
// Bypass mocking when the client is not active.
|
||||
if (!client) {
|
||||
return passthrough()
|
||||
}
|
||||
|
||||
// Bypass initial page load requests (i.e. static assets).
|
||||
// The absence of the immediate/parent client in the map of the active clients
|
||||
// means that MSW hasn't dispatched the "MOCK_ACTIVATE" event yet
|
||||
// and is not ready to handle requests.
|
||||
if (!activeClientIds.has(client.id)) {
|
||||
return passthrough()
|
||||
}
|
||||
|
||||
// Notify the client that a request has been intercepted.
|
||||
const serializedRequest = await serializeRequest(event.request)
|
||||
const clientMessage = await sendToClient(
|
||||
client,
|
||||
{
|
||||
type: 'REQUEST',
|
||||
payload: {
|
||||
id: requestId,
|
||||
interceptedAt: requestInterceptedAt,
|
||||
...serializedRequest,
|
||||
},
|
||||
},
|
||||
[serializedRequest.body],
|
||||
)
|
||||
|
||||
switch (clientMessage.type) {
|
||||
case 'MOCK_RESPONSE': {
|
||||
return respondWithMock(clientMessage.data)
|
||||
}
|
||||
|
||||
case 'PASSTHROUGH': {
|
||||
return passthrough()
|
||||
}
|
||||
}
|
||||
|
||||
return passthrough()
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Client} client
|
||||
* @param {any} message
|
||||
* @param {Array<Transferable>} transferrables
|
||||
* @returns {Promise<any>}
|
||||
*/
|
||||
function sendToClient(client, message, transferrables = []) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const channel = new MessageChannel()
|
||||
|
||||
channel.port1.onmessage = (event) => {
|
||||
if (event.data && event.data.error) {
|
||||
return reject(event.data.error)
|
||||
}
|
||||
|
||||
resolve(event.data)
|
||||
}
|
||||
|
||||
client.postMessage(message, [
|
||||
channel.port2,
|
||||
...transferrables.filter(Boolean),
|
||||
])
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Response} response
|
||||
* @returns {Response}
|
||||
*/
|
||||
function respondWithMock(response) {
|
||||
// Setting response status code to 0 is a no-op.
|
||||
// However, when responding with a "Response.error()", the produced Response
|
||||
// instance will have status code set to 0. Since it's not possible to create
|
||||
// a Response instance with status code 0, handle that use-case separately.
|
||||
if (response.status === 0) {
|
||||
return Response.error()
|
||||
}
|
||||
|
||||
const mockedResponse = new Response(response.body, response)
|
||||
|
||||
Reflect.defineProperty(mockedResponse, IS_MOCKED_RESPONSE, {
|
||||
value: true,
|
||||
enumerable: true,
|
||||
})
|
||||
|
||||
return mockedResponse
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Request} request
|
||||
*/
|
||||
async function serializeRequest(request) {
|
||||
return {
|
||||
url: request.url,
|
||||
mode: request.mode,
|
||||
method: request.method,
|
||||
headers: Object.fromEntries(request.headers.entries()),
|
||||
cache: request.cache,
|
||||
credentials: request.credentials,
|
||||
destination: request.destination,
|
||||
integrity: request.integrity,
|
||||
redirect: request.redirect,
|
||||
referrer: request.referrer,
|
||||
referrerPolicy: request.referrerPolicy,
|
||||
body: await request.arrayBuffer(),
|
||||
keepalive: request.keepalive,
|
||||
}
|
||||
}
|
||||
@ -4,16 +4,11 @@ import { pluginReact } from '@rsbuild/plugin-react';
|
||||
import { pluginTypeCheck } from '@rsbuild/plugin-type-check';
|
||||
|
||||
export default defineConfig({
|
||||
html: {
|
||||
title: 'Investments Control',
|
||||
},
|
||||
plugins: [
|
||||
pluginReact(),
|
||||
pluginEslint({
|
||||
eslintPluginOptions: {
|
||||
configType: 'flat',
|
||||
emitError: false,
|
||||
emitWarning: false,
|
||||
failOnError: false,
|
||||
failOnWarning: false,
|
||||
},
|
||||
@ -22,12 +17,10 @@ export default defineConfig({
|
||||
],
|
||||
server: {
|
||||
proxy: {
|
||||
[`${process.env.PUBLIC_API_BASE_URL}`]: {
|
||||
'/api': {
|
||||
target: 'http://localhost:3000',
|
||||
changeOrigin: true,
|
||||
pathRewrite: {
|
||||
[`^${process.env.PUBLIC_API_BASE_URL}`]: '',
|
||||
},
|
||||
target: process.env.BACKEND_BASE_URL,
|
||||
pathRewrite: { '^/api': '' },
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@ -1,21 +1,34 @@
|
||||
import './normalize.css';
|
||||
import { LocalizationProvider } from '@mui/x-date-pickers';
|
||||
import { AdapterDateFns } from '@mui/x-date-pickers/AdapterDateFns';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { ru } from 'date-fns/locale/ru';
|
||||
import {
|
||||
BrowserRouter,
|
||||
Route,
|
||||
Routes,
|
||||
} from 'react-router';
|
||||
|
||||
import { LoginPage, RegisterPage } from '@/pages/Authorization';
|
||||
import { HomePage } from '@/pages/Home';
|
||||
import { AuthContextProvider } from '@/shared/context/AuthContext';
|
||||
|
||||
import Routing from './Routing';
|
||||
import { AuthRoute } from '@/shared/ui/AuthRoute';
|
||||
|
||||
const queryClient = new QueryClient();
|
||||
|
||||
const App = () => (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<AuthContextProvider>
|
||||
<LocalizationProvider adapterLocale={ru} dateAdapter={AdapterDateFns}>
|
||||
<Routing />
|
||||
</LocalizationProvider>
|
||||
<BrowserRouter>
|
||||
<Routes>
|
||||
<Route element={<AuthRoute />}>
|
||||
<Route element={<HomePage />} path="/" />
|
||||
</Route>
|
||||
|
||||
<Route element={<AuthRoute needAuth={false} to="/" />}>
|
||||
<Route element={<LoginPage />} path="/login" />
|
||||
|
||||
<Route element={<RegisterPage />} path="/register" />
|
||||
</Route>
|
||||
</Routes>
|
||||
</BrowserRouter>
|
||||
</AuthContextProvider>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
|
||||
@ -1,42 +0,0 @@
|
||||
import { BrowserRouter, Route, Routes } from 'react-router';
|
||||
|
||||
import { LoginPage, RegisterPage } from '@/pages/Authorization';
|
||||
import { BankCardsPage } from '@/pages/BankCard';
|
||||
import { CashPage } from '@/pages/Cash';
|
||||
import { ExpensePage } from '@/pages/Expense';
|
||||
import { ExpensesPage } from '@/pages/Expenses';
|
||||
import { HomePage } from '@/pages/Home';
|
||||
import { IncomePage } from '@/pages/Income';
|
||||
import { IncomesPage } from '@/pages/Incomes';
|
||||
import { PAGES } from '@/shared/router';
|
||||
import { AuthRoute } from '@/shared/ui/AuthRoute';
|
||||
|
||||
const Routing = () => (
|
||||
<BrowserRouter>
|
||||
<Routes>
|
||||
<Route element={<AuthRoute />}>
|
||||
<Route element={<HomePage />} path={PAGES.Home} />
|
||||
|
||||
<Route element={<ExpensesPage />} path={PAGES.Expenses} />
|
||||
|
||||
<Route element={<ExpensePage />} path={PAGES.Expense} />
|
||||
|
||||
<Route element={<IncomesPage />} path={PAGES.Incomes} />
|
||||
|
||||
<Route element={<IncomePage />} path={PAGES.Income} />
|
||||
|
||||
<Route element={<BankCardsPage />} path={PAGES.BankCards} />
|
||||
|
||||
<Route element={<CashPage />} path={PAGES.Cash} />
|
||||
</Route>
|
||||
|
||||
<Route element={<AuthRoute needAuth={false} to="/" />}>
|
||||
<Route element={<LoginPage />} path={PAGES.Login} />
|
||||
|
||||
<Route element={<RegisterPage />} path={PAGES.Register} />
|
||||
</Route>
|
||||
</Routes>
|
||||
</BrowserRouter>
|
||||
);
|
||||
|
||||
export default Routing;
|
||||
4
frontend/src/app/App/ui/normalize.css
vendored
4
frontend/src/app/App/ui/normalize.css
vendored
@ -347,7 +347,3 @@ template {
|
||||
[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
@ -1,12 +0,0 @@
|
||||
import type { paths } from '@/shared/api/schema';
|
||||
|
||||
import { api } from '@/shared/api/client';
|
||||
|
||||
type LoginRequestBody = paths['/auth/login']['post']['requestBody']['content']['application/json'];
|
||||
type LoginSuccessResponse = paths['/auth/login']['post']['responses']['200']['content']['application/json'];
|
||||
|
||||
export async function login(data: LoginRequestBody): Promise<LoginSuccessResponse> {
|
||||
const { data: result } = await api.post<LoginSuccessResponse>('/auth/login', data);
|
||||
|
||||
return result;
|
||||
}
|
||||
@ -1,11 +0,0 @@
|
||||
import type { paths } from '@/shared/api/schema';
|
||||
|
||||
import { api } from '@/shared/api/client';
|
||||
|
||||
type LogoutSuccessResponse = paths['/auth/logout']['post']['responses']['200']['content']['application/json'];
|
||||
|
||||
export async function logout() {
|
||||
const { data: result } = await api.post<LogoutSuccessResponse>('/auth/logout');
|
||||
|
||||
return result;
|
||||
}
|
||||
@ -1,11 +0,0 @@
|
||||
import { useMutation } from '@tanstack/react-query';
|
||||
|
||||
import { register } from '../api/register';
|
||||
|
||||
export const useRegister = () => useMutation({
|
||||
mutationFn: register,
|
||||
onError: (error) => {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log('Неуспешная регистрация', error);
|
||||
},
|
||||
});
|
||||
@ -1,3 +1,3 @@
|
||||
export { useLogin } from './hooks/useLogin';
|
||||
export { useLogout } from './hooks/useLogout';
|
||||
export { useRegister } from './hooks/useRegister';
|
||||
export { useLogin } from './login/useLogin';
|
||||
export { useRegister } from './register/useRegister';
|
||||
export { useLogout } from './logout/useLogout';
|
||||
|
||||
@ -2,10 +2,19 @@ import { useMutation } from '@tanstack/react-query';
|
||||
import { useContext } from 'react';
|
||||
import { useNavigate } from 'react-router';
|
||||
|
||||
import { api } from '@/shared/api/client';
|
||||
import { AuthContext } from '@/shared/context/AuthContext';
|
||||
import { PAGES } from '@/shared/router';
|
||||
|
||||
import { login } from '../api/login';
|
||||
import type { paths } from '@/shared/api/schema';
|
||||
|
||||
type LoginRequestBody = paths['/auth/login']['post']['requestBody']['content']['application/json'];
|
||||
type LoginSuccessResponse = paths['/auth/login']['post']['responses']['200']['content']['application/json'];
|
||||
|
||||
async function login(data: LoginRequestBody): Promise<LoginSuccessResponse> {
|
||||
const { data: result } = await api.post<LoginSuccessResponse>('/auth/login', data);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
export const useLogin = () => {
|
||||
const navigate = useNavigate();
|
||||
@ -13,16 +22,16 @@ export const useLogin = () => {
|
||||
|
||||
return useMutation({
|
||||
mutationFn: login,
|
||||
onSuccess: async (data) => {
|
||||
setAccessToken(data.accessToken);
|
||||
localStorage.setItem('accessToken', data.accessToken);
|
||||
await navigate('/another');
|
||||
},
|
||||
onError: (error) => {
|
||||
// eslint-disable-next-line
|
||||
console.log('Неуспешный логин', error);
|
||||
|
||||
throw error;
|
||||
},
|
||||
onSuccess: async (data) => {
|
||||
setAccessToken(data.accessToken);
|
||||
localStorage.setItem('accessToken', data.accessToken);
|
||||
await navigate(PAGES.Home);
|
||||
},
|
||||
});
|
||||
};
|
||||
@ -2,10 +2,18 @@ import { useMutation } from '@tanstack/react-query';
|
||||
import { useContext } from 'react';
|
||||
import { useNavigate } from 'react-router';
|
||||
|
||||
import { api } from '@/shared/api/client';
|
||||
import { AuthContext } from '@/shared/context/AuthContext';
|
||||
import { PAGES } from '@/shared/router';
|
||||
|
||||
import { logout } from '../api/logout';
|
||||
import type { paths } from '@/shared/api/schema';
|
||||
|
||||
type LogoutSuccessResponse = paths['/auth/logout']['post']['responses']['200']['content']['application/json'];
|
||||
|
||||
async function logout() {
|
||||
const { data: result } = await api.post<LogoutSuccessResponse>('/auth/logout');
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
export const useLogout = () => {
|
||||
const navigate = useNavigate();
|
||||
@ -16,7 +24,7 @@ export const useLogout = () => {
|
||||
onSuccess: async () => {
|
||||
setAccessToken('');
|
||||
localStorage.removeItem('accessToken');
|
||||
await navigate(PAGES.Login);
|
||||
await navigate('/login');
|
||||
},
|
||||
});
|
||||
};
|
||||
@ -1,12 +1,22 @@
|
||||
import type { paths } from '@/shared/api/schema';
|
||||
import { useMutation } from '@tanstack/react-query';
|
||||
|
||||
import { api } from '@/shared/api/client';
|
||||
|
||||
import type { paths } from '@/shared/api/schema';
|
||||
|
||||
type RegisterRequestBody = paths['/auth/register']['post']['requestBody']['content']['application/json'];
|
||||
type RegisterSuccessResponse = paths['/auth/register']['post']['responses']['200']['content']['application/json'];
|
||||
|
||||
export async function register(data: RegisterRequestBody) {
|
||||
async function register(data: RegisterRequestBody) {
|
||||
const { data: result } = await api.post<RegisterSuccessResponse>('auth/register', data);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
export const useRegister = () => useMutation({
|
||||
mutationFn: register,
|
||||
onError: (error) => {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log('Неуспешная регистрация', error);
|
||||
},
|
||||
});
|
||||
@ -1,12 +0,0 @@
|
||||
import type { paths } from '@/shared/api/schema';
|
||||
|
||||
import { api } from '@/shared/api/client';
|
||||
|
||||
export type AddBankCardBody = paths['/bank-card']['post']['requestBody']['content']['application/json'];
|
||||
export type AddBankCardResponse = paths['/bank-card']['post']['responses']['200']['content']['application/json'];
|
||||
|
||||
export async function addBankCard(data: AddBankCardBody) {
|
||||
const { data: result } = await api.post<AddBankCardResponse>('/bank-card', data);
|
||||
|
||||
return result;
|
||||
}
|
||||
@ -1,12 +0,0 @@
|
||||
import type { paths } from '@/shared/api/schema';
|
||||
|
||||
import { api } from '@/shared/api/client';
|
||||
|
||||
export type DeleteBankCardPath = paths['/bank-card/{id}']['delete']['parameters']['path'];
|
||||
export type DeleteBankCardResponse = paths['/bank-card/{id}']['delete']['responses']['200']['content']['application/json'];
|
||||
|
||||
export async function deleteBankCard(path: DeleteBankCardPath) {
|
||||
const { data: result } = await api.delete<DeleteBankCardResponse>(`/bank-card/${path.id}`);
|
||||
|
||||
return result;
|
||||
}
|
||||
@ -1,13 +0,0 @@
|
||||
import type { paths } from '@/shared/api/schema';
|
||||
|
||||
import { api } from '@/shared/api/client';
|
||||
|
||||
export type EditBankCardBody = paths['/bank-card/{id}']['patch']['requestBody']['content']['application/json'];
|
||||
export type EditBankCardPath = paths['/bank-card/{id}']['patch']['parameters']['path'];
|
||||
export type EditBankCardResponse = paths['/bank-card/{id}']['patch']['responses']['200']['content']['application/json'];
|
||||
|
||||
export async function editBankCard({ data, path }: { data: EditBankCardBody; path: EditBankCardPath }) {
|
||||
const { data: result } = await api.patch<EditBankCardResponse>(`/bank-card/${path.id}`, data);
|
||||
|
||||
return result;
|
||||
}
|
||||
@ -1,11 +0,0 @@
|
||||
import type { paths } from '@/shared/api/schema';
|
||||
|
||||
import { api } from '@/shared/api/client';
|
||||
|
||||
type BankCardsResponse = paths['/bank-card']['get']['responses']['200']['content']['application/json'];
|
||||
|
||||
export async function getBankCards() {
|
||||
const { data: result } = await api.get<BankCardsResponse>('/bank-card');
|
||||
|
||||
return result;
|
||||
}
|
||||
@ -1,17 +0,0 @@
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
|
||||
import { QueryKeys } from '@/shared/api/queryKeys';
|
||||
|
||||
import { addBankCard } from '../api/addBankCard';
|
||||
|
||||
export const useAddBankCard = () => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: addBankCard,
|
||||
mutationKey: [QueryKeys.BankCards],
|
||||
onSuccess: async () => {
|
||||
await queryClient.invalidateQueries({ queryKey: [QueryKeys.BankCards] });
|
||||
},
|
||||
});
|
||||
};
|
||||
@ -1,10 +0,0 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
|
||||
import { QueryKeys } from '@/shared/api/queryKeys';
|
||||
|
||||
import { getBankCards } from '../api/getBankCards';
|
||||
|
||||
export const useBankCards = () => useQuery({
|
||||
queryFn: async () => getBankCards(),
|
||||
queryKey: [QueryKeys.BankCards],
|
||||
});
|
||||
@ -1,17 +0,0 @@
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
|
||||
import { QueryKeys } from '@/shared/api/queryKeys';
|
||||
|
||||
import { deleteBankCard } from '../api/deleteBankCard';
|
||||
|
||||
export const useDeleteBankCard = () => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: deleteBankCard,
|
||||
mutationKey: [QueryKeys.BankCards],
|
||||
onSuccess: async () => {
|
||||
await queryClient.invalidateQueries({ queryKey: [QueryKeys.BankCards] });
|
||||
},
|
||||
});
|
||||
};
|
||||
@ -1,17 +0,0 @@
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
|
||||
import { QueryKeys } from '@/shared/api/queryKeys';
|
||||
|
||||
import { editBankCard } from '../api/editBankCard';
|
||||
|
||||
export const useEditBankCard = () => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: editBankCard,
|
||||
mutationKey: [QueryKeys.BankCards],
|
||||
onSuccess: async () => {
|
||||
await queryClient.invalidateQueries({ queryKey: [QueryKeys.BankCards] });
|
||||
},
|
||||
});
|
||||
};
|
||||
@ -1,19 +0,0 @@
|
||||
export type { AddBankCardBody, AddBankCardResponse } from './api/addBankCard';
|
||||
export type { DeleteBankCardPath, DeleteBankCardResponse } from './api/deleteBankCard';
|
||||
export type { EditBankCardBody, EditBankCardPath, EditBankCardResponse } from './api/editBankCard';
|
||||
|
||||
export { useAddBankCard } from './hooks/useAddBankCard';
|
||||
export { useBankCards } from './hooks/useBankCards';
|
||||
export { useDeleteBankCard } from './hooks/useDeleteBankCard';
|
||||
export { useEditBankCard } from './hooks/useEditBankCard';
|
||||
|
||||
export type { BankCard, BankCards } from './types';
|
||||
|
||||
export type { AddBankCardFormProps, AddBankCardFormValues } from './ui/AddBankCardForm';
|
||||
export { AddBankCardForm } from './ui/AddBankCardForm';
|
||||
|
||||
export type { DeleteBankCardFormProps } from './ui/DeleteBankCardForm';
|
||||
export { DeleteBankCardForm } from './ui/DeleteBankCardForm';
|
||||
|
||||
export type { EditBankCardFormProps, EditBankCardFormValues } from './ui/EditBankCardForm';
|
||||
export { EditBankCardForm } from './ui/EditBankCardForm';
|
||||
@ -1,4 +0,0 @@
|
||||
import type { paths } from '@/shared/api/schema';
|
||||
|
||||
export type BankCard = paths['/bank-card/{id}']['get']['responses']['200']['content']['application/json'];
|
||||
export type BankCards = paths['/bank-card']['get']['responses']['200']['content']['application/json'];
|
||||
@ -1,3 +0,0 @@
|
||||
export type { AddBankCardFormValues } from './ui/AddBankCardForm.ui';
|
||||
export type { AddBankCardFormProps } from './ui/AddBankCardForm.ui';
|
||||
export { default as AddBankCardForm } from './ui/AddBankCardForm.ui';
|
||||
@ -1,34 +0,0 @@
|
||||
import { AddBankCardFormHelper } from '@tests/helpers';
|
||||
|
||||
describe('Test AddBankCardForm', () => {
|
||||
test('should render form', () => {
|
||||
AddBankCardFormHelper.renderForm();
|
||||
const { formElem } = AddBankCardFormHelper.getFormElements();
|
||||
expect(formElem).toBeInTheDocument();
|
||||
expect(formElem).toBeVisible();
|
||||
});
|
||||
|
||||
test.each([
|
||||
['enabled', {}, (el: HTMLElement) => { expect(el).toBeEnabled(); }],
|
||||
['disabled', { disabled: true }, (el: HTMLElement) => { expect(el).toBeDisabled(); }],
|
||||
])('should render fields %s', (_state, options, assertion) => {
|
||||
AddBankCardFormHelper.renderForm(options);
|
||||
const { formElem, ...formElems } = AddBankCardFormHelper.getFormElements();
|
||||
Object.values(formElems).forEach(assertion);
|
||||
});
|
||||
|
||||
test('should call onSubmit with values', async () => {
|
||||
const { onSubmit } = AddBankCardFormHelper.renderForm();
|
||||
|
||||
await AddBankCardFormHelper.fillForm({
|
||||
balance: 500,
|
||||
name: 'Тестовая карта',
|
||||
});
|
||||
|
||||
expect(onSubmit).toHaveBeenCalledTimes(1);
|
||||
expect(onSubmit).toHaveBeenCalledWith({
|
||||
balance: 500,
|
||||
name: 'Тестовая карта',
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -1,93 +0,0 @@
|
||||
import type {
|
||||
FC,
|
||||
FormEventHandler,
|
||||
FormHTMLAttributes,
|
||||
RefObject,
|
||||
} from 'react';
|
||||
|
||||
import { Button, TextField, Typography } from '@mui/material';
|
||||
|
||||
import { DataTestId } from '@/shared/constants';
|
||||
|
||||
import classes from './AddBankCardForm.module.css';
|
||||
|
||||
export type AddBankCardFormProps = BaseFormProps & {
|
||||
disabled?: boolean;
|
||||
onSubmit?: (data: AddBankCardFormValues) => Promise<void> | void;
|
||||
ref?: RefObject<HTMLFormElement>;
|
||||
};
|
||||
|
||||
export type AddBankCardFormValues = {
|
||||
balance: number;
|
||||
name: string;
|
||||
};
|
||||
|
||||
type BaseFormProps = Omit<FormHTMLAttributes<HTMLFormElement>, 'onSubmit'>;
|
||||
|
||||
const AddBankCardForm: FC<AddBankCardFormProps> = (props) => {
|
||||
const {
|
||||
disabled,
|
||||
onSubmit,
|
||||
ref,
|
||||
...formProps
|
||||
} = props;
|
||||
|
||||
const onFormSubmitHandler: FormEventHandler<HTMLFormElement> = (event) => {
|
||||
event.preventDefault();
|
||||
|
||||
const formData = new FormData(event.currentTarget);
|
||||
|
||||
const name = formData.get('name');
|
||||
const balance = formData.get('balance');
|
||||
|
||||
if (typeof name === 'string' && typeof balance === 'string') {
|
||||
onSubmit?.({
|
||||
balance: Number(balance),
|
||||
name,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<form
|
||||
className={classes.form}
|
||||
data-testid={DataTestId.ADD_BANK_CARD_FORM}
|
||||
onSubmit={onFormSubmitHandler}
|
||||
ref={ref}
|
||||
{...formProps}
|
||||
>
|
||||
<Typography variant="h6">
|
||||
Добавить карту
|
||||
</Typography>
|
||||
|
||||
<TextField
|
||||
disabled={disabled}
|
||||
label="Название"
|
||||
name="name"
|
||||
required
|
||||
size="small"
|
||||
/>
|
||||
|
||||
<TextField
|
||||
defaultValue={0}
|
||||
disabled={disabled}
|
||||
inputMode="numeric"
|
||||
label="Баланс"
|
||||
name="balance"
|
||||
size="small"
|
||||
/>
|
||||
|
||||
<Button
|
||||
data-testid={DataTestId.ADD_BANK_CARD_SUBMIT_BUTTON}
|
||||
disabled={disabled}
|
||||
sx={{ mt: 2 }}
|
||||
type="submit"
|
||||
variant="contained"
|
||||
>
|
||||
Добавить
|
||||
</Button>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
|
||||
export default AddBankCardForm;
|
||||
@ -1,2 +0,0 @@
|
||||
export type { DeleteBankCardFormProps } from './ui/DeleteBankCardForm.ui';
|
||||
export { default as DeleteBankCardForm } from './ui/DeleteBankCardForm.ui';
|
||||
@ -1,18 +0,0 @@
|
||||
import { DeleteBankCardFormHelper } from '@tests/helpers';
|
||||
|
||||
describe('Test DeleteExpensesListForm', () => {
|
||||
test('should render form', () => {
|
||||
DeleteBankCardFormHelper.renderForm();
|
||||
const { formElem } = DeleteBankCardFormHelper.getFormElements();
|
||||
expect(formElem).toBeInTheDocument();
|
||||
expect(formElem).toBeVisible();
|
||||
});
|
||||
|
||||
test('should call onSubmit with values', async () => {
|
||||
const { onSubmit } = DeleteBankCardFormHelper.renderForm();
|
||||
await DeleteBankCardFormHelper.submitForm();
|
||||
|
||||
expect(onSubmit).toHaveBeenCalledTimes(1);
|
||||
expect(onSubmit).toHaveBeenCalledWith(DeleteBankCardFormHelper.bankCard);
|
||||
});
|
||||
});
|
||||
@ -1,75 +0,0 @@
|
||||
import type {
|
||||
FC,
|
||||
FormEventHandler,
|
||||
FormHTMLAttributes,
|
||||
RefObject,
|
||||
} from 'react';
|
||||
|
||||
import { Button, Typography } from '@mui/material';
|
||||
|
||||
import { DataTestId } from '@/shared/constants';
|
||||
|
||||
import type { BankCard } from '../../../types';
|
||||
|
||||
export type DeleteBankCardFormProps = BaseFormProps & {
|
||||
bankCard: BankCard;
|
||||
disabled?: boolean;
|
||||
onSubmit?: (bankCard: BankCard) => Promise<void> | void;
|
||||
ref?: RefObject<HTMLFormElement>;
|
||||
};
|
||||
|
||||
type BaseFormProps = Omit<FormHTMLAttributes<HTMLFormElement>, 'onSubmit'>;
|
||||
|
||||
const DeleteBankCardForm: FC<DeleteBankCardFormProps> = (props) => {
|
||||
const {
|
||||
bankCard,
|
||||
disabled,
|
||||
onSubmit,
|
||||
ref,
|
||||
...restProps
|
||||
} = props;
|
||||
|
||||
const onFormSubmitHandler: FormEventHandler<HTMLFormElement> = (event) => {
|
||||
event.preventDefault();
|
||||
onSubmit?.(bankCard);
|
||||
};
|
||||
|
||||
return (
|
||||
<form
|
||||
data-testid={DataTestId.DELETE_BANK_CARD_FORM}
|
||||
onSubmit={onFormSubmitHandler}
|
||||
ref={ref}
|
||||
{...restProps}
|
||||
>
|
||||
<Typography variant="h6">
|
||||
Удалить список расходов
|
||||
</Typography>
|
||||
|
||||
<Typography variant="body1">
|
||||
Вы действительно хотите удалить банковскую карту
|
||||
{' '}
|
||||
|
||||
<b>
|
||||
{`"${bankCard.name}"`}
|
||||
</b>
|
||||
|
||||
{' '}
|
||||
?
|
||||
</Typography>
|
||||
|
||||
<Button
|
||||
color="error"
|
||||
data-testid={DataTestId.DELETE_BANK_CARD_SUBMIT_BUTTON}
|
||||
disabled={disabled}
|
||||
fullWidth
|
||||
sx={{ mt: 2 }}
|
||||
type="submit"
|
||||
variant="contained"
|
||||
>
|
||||
Удалить
|
||||
</Button>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
|
||||
export default DeleteBankCardForm;
|
||||
@ -1,3 +0,0 @@
|
||||
export type { EditBankCardFormValues } from './ui/EditBankCardForm.ui';
|
||||
export type { EditBankCardFormProps } from './ui/EditBankCardForm.ui';
|
||||
export { default as EditBankCardForm } from './ui/EditBankCardForm.ui';
|
||||
@ -1,5 +0,0 @@
|
||||
.form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
@ -1,38 +0,0 @@
|
||||
import { EditBankCardFormHelper } from '@tests/helpers';
|
||||
|
||||
describe('Test EditBankCardForm', () => {
|
||||
test('should render form', () => {
|
||||
EditBankCardFormHelper.renderForm();
|
||||
const { formElem } = EditBankCardFormHelper.getFormElements();
|
||||
expect(formElem).toBeInTheDocument();
|
||||
expect(formElem).toBeVisible();
|
||||
});
|
||||
|
||||
test('should inputs have default value', () => {
|
||||
EditBankCardFormHelper.renderForm();
|
||||
const { balanceInputElem, nameInputElem } = EditBankCardFormHelper.getFormElements();
|
||||
expect(nameInputElem).toHaveValue('Сбербанк');
|
||||
expect(balanceInputElem).toHaveValue('0');
|
||||
});
|
||||
|
||||
test.each([
|
||||
['enabled', {}, (el: HTMLElement) => { expect(el).toBeEnabled(); }],
|
||||
['disabled', { disabled: true }, (el: HTMLElement) => { expect(el).toBeDisabled(); }],
|
||||
])('should render fields %s', (_state, options, assertion) => {
|
||||
EditBankCardFormHelper.renderForm(options);
|
||||
const { formElem, ...formElems } = EditBankCardFormHelper.getFormElements();
|
||||
Object.values(formElems).forEach(assertion);
|
||||
});
|
||||
|
||||
test('should call onSubmit with values', async () => {
|
||||
const { onSubmit } = EditBankCardFormHelper.renderForm();
|
||||
|
||||
const data = {
|
||||
balance: 5000,
|
||||
name: 'Новое название карты',
|
||||
};
|
||||
await EditBankCardFormHelper.fillForm(data);
|
||||
expect(onSubmit).toHaveBeenCalledTimes(1);
|
||||
expect(onSubmit).toHaveBeenCalledWith(data);
|
||||
});
|
||||
});
|
||||
@ -1,98 +0,0 @@
|
||||
import type {
|
||||
FC,
|
||||
FormEventHandler,
|
||||
FormHTMLAttributes,
|
||||
RefObject,
|
||||
} from 'react';
|
||||
|
||||
import { Button, TextField, Typography } from '@mui/material';
|
||||
|
||||
import type { BankCard } from '@/entity/cards';
|
||||
|
||||
import { DataTestId } from '@/shared/constants';
|
||||
|
||||
import classes from './EditBankCardForm.module.css';
|
||||
|
||||
export type EditBankCardFormProps = BaseFormProps & {
|
||||
bankCard: BankCard;
|
||||
disabled?: boolean;
|
||||
onSubmit?: (data: EditBankCardFormValues) => Promise<void> | void;
|
||||
ref?: RefObject<HTMLFormElement>;
|
||||
};
|
||||
|
||||
export type EditBankCardFormValues = {
|
||||
balance: number;
|
||||
name: string;
|
||||
};
|
||||
|
||||
type BaseFormProps = Omit<FormHTMLAttributes<HTMLFormElement>, 'onSubmit'>;
|
||||
|
||||
const EditBankCardForm: FC<EditBankCardFormProps> = (props) => {
|
||||
const {
|
||||
bankCard,
|
||||
disabled,
|
||||
onSubmit,
|
||||
ref,
|
||||
...restProps
|
||||
} = props;
|
||||
|
||||
const onFormSubmitHandler: FormEventHandler<HTMLFormElement> = (event) => {
|
||||
event.preventDefault();
|
||||
|
||||
const formData = new FormData(event.currentTarget);
|
||||
|
||||
const name = formData.get('name');
|
||||
const balance = formData.get('balance');
|
||||
|
||||
if (typeof name === 'string' && typeof balance === 'string') {
|
||||
onSubmit?.({
|
||||
balance: Number(balance),
|
||||
name,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<form
|
||||
className={classes.form}
|
||||
data-testid={DataTestId.EDIT_BANK_CARD_FORM}
|
||||
onSubmit={onFormSubmitHandler}
|
||||
ref={ref}
|
||||
{...restProps}
|
||||
>
|
||||
<Typography variant="h6">
|
||||
Добавить карту
|
||||
</Typography>
|
||||
|
||||
<TextField
|
||||
defaultValue={bankCard.name}
|
||||
disabled={disabled}
|
||||
label="Название"
|
||||
name="name"
|
||||
required
|
||||
size="small"
|
||||
/>
|
||||
|
||||
<TextField
|
||||
defaultValue={bankCard.balance}
|
||||
disabled={disabled}
|
||||
inputMode="numeric"
|
||||
label="Баланс"
|
||||
name="balance"
|
||||
size="small"
|
||||
/>
|
||||
|
||||
<Button
|
||||
data-testid={DataTestId.EDIT_BANK_CARD_SUBMIT_BUTTON}
|
||||
disabled={disabled}
|
||||
fullWidth
|
||||
type="submit"
|
||||
variant="contained"
|
||||
>
|
||||
Сохранить
|
||||
</Button>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
|
||||
export default EditBankCardForm;
|
||||
@ -1,12 +0,0 @@
|
||||
import type { paths } from '@/shared/api/schema';
|
||||
|
||||
import { api } from '@/shared/api/client';
|
||||
|
||||
export type AdCashResponse = paths['/cash']['post']['responses']['200']['content']['application/json'];
|
||||
export type AddCashBody = paths['/cash']['post']['requestBody']['content']['application/json'];
|
||||
|
||||
export async function addCash(data: AddCashBody) {
|
||||
const { data: result } = await api.post<AdCashResponse>('/cash', data);
|
||||
|
||||
return result;
|
||||
}
|
||||
@ -1,12 +0,0 @@
|
||||
import type { paths } from '@/shared/api/schema';
|
||||
|
||||
import { api } from '@/shared/api/client';
|
||||
|
||||
export type DeleteCashPath = paths['/cash/{id}']['delete']['parameters']['path'];
|
||||
export type DeleteCashResponse = paths['/cash/{id}']['delete']['responses']['200']['content']['application/json'];
|
||||
|
||||
export async function deleteCash(path: DeleteCashPath) {
|
||||
const { data: result } = await api.delete<DeleteCashResponse>(`/cash/${path.id}`);
|
||||
|
||||
return result;
|
||||
}
|
||||
@ -1,13 +0,0 @@
|
||||
import type { paths } from '@/shared/api/schema';
|
||||
|
||||
import { api } from '@/shared/api/client';
|
||||
|
||||
export type EditCashBody = paths['/cash/{id}']['patch']['requestBody']['content']['application/json'];
|
||||
export type EditCashPath = paths['/cash/{id}']['patch']['parameters']['path'];
|
||||
export type EditCashResponse = paths['/cash/{id}']['patch']['responses']['200']['content']['application/json'];
|
||||
|
||||
export async function editCash({ data, path }: { data: EditCashBody; path: EditCashPath }) {
|
||||
const { data: result } = await api.patch<EditCashResponse>(`/cash/${path.id}`, data);
|
||||
|
||||
return result;
|
||||
}
|
||||
@ -1,11 +0,0 @@
|
||||
import type { paths } from '@/shared/api/schema';
|
||||
|
||||
import { api } from '@/shared/api/client';
|
||||
|
||||
type CashResponse = paths['/cash']['get']['responses']['200']['content']['application/json'];
|
||||
|
||||
export async function getCash() {
|
||||
const { data: result } = await api.get<CashResponse>('/cash');
|
||||
|
||||
return result;
|
||||
}
|
||||
@ -1,17 +0,0 @@
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
|
||||
import { QueryKeys } from '@/shared/api/queryKeys';
|
||||
|
||||
import { addCash } from '../api/addCash';
|
||||
|
||||
export const useAddCash = () => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: addCash,
|
||||
mutationKey: [QueryKeys.Cash],
|
||||
onSuccess: async () => {
|
||||
await queryClient.invalidateQueries({ queryKey: [QueryKeys.Cash] });
|
||||
},
|
||||
});
|
||||
};
|
||||
@ -1,10 +0,0 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
|
||||
import { QueryKeys } from '@/shared/api/queryKeys';
|
||||
|
||||
import { getCash } from '../api/getCash';
|
||||
|
||||
export const useCash = () => useQuery({
|
||||
queryFn: async () => getCash(),
|
||||
queryKey: [QueryKeys.Cash],
|
||||
});
|
||||
@ -1,17 +0,0 @@
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
|
||||
import { QueryKeys } from '@/shared/api/queryKeys';
|
||||
|
||||
import { deleteCash } from '../api/deleteCash';
|
||||
|
||||
export const useDeleteCash = () => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: deleteCash,
|
||||
mutationKey: [QueryKeys.Cash],
|
||||
onSuccess: async () => {
|
||||
await queryClient.invalidateQueries({ queryKey: [QueryKeys.Cash] });
|
||||
},
|
||||
});
|
||||
};
|
||||
@ -1,17 +0,0 @@
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
|
||||
import { QueryKeys } from '@/shared/api/queryKeys';
|
||||
|
||||
import { editCash } from '../api/editCash';
|
||||
|
||||
export const useEditCash = () => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: editCash,
|
||||
mutationKey: [QueryKeys.Cash],
|
||||
onSuccess: async () => {
|
||||
await queryClient.invalidateQueries({ queryKey: [QueryKeys.Cash] });
|
||||
},
|
||||
});
|
||||
};
|
||||
@ -1,19 +0,0 @@
|
||||
export type { AdCashResponse, AddCashBody } from './api/addCash';
|
||||
export type { DeleteCashPath, DeleteCashResponse } from './api/deleteCash';
|
||||
export type { EditCashBody, EditCashPath, EditCashResponse } from './api/editCash';
|
||||
|
||||
export { useAddCash } from './hooks/useAddCash';
|
||||
export { useCash } from './hooks/useCash';
|
||||
export { useDeleteCash } from './hooks/useDeleteCash';
|
||||
export { useEditCash } from './hooks/useEditCash';
|
||||
|
||||
export type { CashItem, CashList } from './types';
|
||||
|
||||
export { AddCashForm } from './ui/AddCashForm';
|
||||
export type { AddCashFormProps, AddCashFormValues } from './ui/AddCashForm';
|
||||
|
||||
export type { DeleteCashFormProps } from './ui/DeleteCashForm';
|
||||
export { DeleteCashForm } from './ui/DeleteCashForm';
|
||||
|
||||
export { EditCashForm } from './ui/EditCashForm';
|
||||
export type { EditCashFormProps, EditCashFormValues } from './ui/EditCashForm';
|
||||
@ -1,4 +0,0 @@
|
||||
import type { paths } from '@/shared/api/schema';
|
||||
|
||||
export type CashItem = paths['/cash/{id}']['get']['responses']['200']['content']['application/json'];
|
||||
export type CashList = paths['/cash']['get']['responses']['200']['content']['application/json'];
|
||||
@ -1,2 +0,0 @@
|
||||
export type { AddCashFormProps, AddCashFormValues } from './ui/AddCashForm.ui';
|
||||
export { default as AddCashForm } from './ui/AddCashForm.ui';
|
||||
@ -1,5 +0,0 @@
|
||||
.form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
@ -1,28 +0,0 @@
|
||||
import { AddCashFormHelper } from '@tests/helpers';
|
||||
|
||||
describe('Test AddCashForm', () => {
|
||||
test('should render form', () => {
|
||||
AddCashFormHelper.renderForm();
|
||||
const { formElem } = AddCashFormHelper.getFormElements();
|
||||
expect(formElem).toBeInTheDocument();
|
||||
expect(formElem).toBeVisible();
|
||||
});
|
||||
|
||||
test.each([
|
||||
['enabled', {}, (el: HTMLElement) => { expect(el).toBeEnabled(); }],
|
||||
['disabled', { disabled: true }, (el: HTMLElement) => { expect(el).toBeDisabled(); }],
|
||||
])('should render fields %s', (_state, options, assertion) => {
|
||||
AddCashFormHelper.renderForm(options);
|
||||
const { formElem, ...formElems } = AddCashFormHelper.getFormElements();
|
||||
Object.values(formElems).forEach(assertion);
|
||||
});
|
||||
|
||||
test('should call onSubmit with values', async () => {
|
||||
const { onSubmit } = AddCashFormHelper.renderForm();
|
||||
|
||||
const data = { balance: 500, name: 'Наличные' };
|
||||
await AddCashFormHelper.fillForm(data);
|
||||
expect(onSubmit).toHaveBeenCalledTimes(1);
|
||||
expect(onSubmit).toHaveBeenCalledWith(data);
|
||||
});
|
||||
});
|
||||
@ -1,92 +0,0 @@
|
||||
import type {
|
||||
FC,
|
||||
FormEventHandler,
|
||||
FormHTMLAttributes,
|
||||
RefObject,
|
||||
} from 'react';
|
||||
|
||||
import { Button, TextField, Typography } from '@mui/material';
|
||||
|
||||
import { DataTestId } from '@/shared/constants';
|
||||
|
||||
import classes from './AddCashForm.module.css';
|
||||
|
||||
export type AddCashFormProps = BaseFormProps & {
|
||||
disabled?: boolean;
|
||||
onSubmit?: (data: AddCashFormValues) => Promise<void> | void;
|
||||
ref?: RefObject<HTMLFormElement>;
|
||||
};
|
||||
|
||||
export type AddCashFormValues = {
|
||||
balance: number;
|
||||
name: string;
|
||||
};
|
||||
|
||||
type BaseFormProps = Omit<FormHTMLAttributes<HTMLFormElement>, 'onSubmit'>;
|
||||
|
||||
const AddCashForm: FC<AddCashFormProps> = (props) => {
|
||||
const {
|
||||
disabled,
|
||||
onSubmit,
|
||||
ref,
|
||||
...formProps
|
||||
} = props;
|
||||
|
||||
const onFormSubmitHandler: FormEventHandler<HTMLFormElement> = (event) => {
|
||||
event.preventDefault();
|
||||
|
||||
const formData = new FormData(event.currentTarget);
|
||||
|
||||
const name = formData.get('name');
|
||||
const balance = formData.get('balance');
|
||||
|
||||
if (typeof name === 'string' && typeof balance === 'string') {
|
||||
onSubmit?.({
|
||||
balance: Number(balance),
|
||||
name,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<form
|
||||
className={classes.form}
|
||||
data-testid={DataTestId.ADD_CASH_FORM}
|
||||
onSubmit={onFormSubmitHandler}
|
||||
ref={ref}
|
||||
{...formProps}
|
||||
>
|
||||
<Typography variant="h6">
|
||||
Добавить наличные
|
||||
</Typography>
|
||||
|
||||
<TextField
|
||||
disabled={disabled}
|
||||
label="Название"
|
||||
name="name"
|
||||
required
|
||||
size="small"
|
||||
/>
|
||||
|
||||
<TextField
|
||||
defaultValue={0}
|
||||
disabled={disabled}
|
||||
inputMode="numeric"
|
||||
label="Баланс"
|
||||
name="balance"
|
||||
size="small"
|
||||
/>
|
||||
|
||||
<Button
|
||||
data-testid={DataTestId.ADD_CASH_SUBMIT_BUTTON}
|
||||
disabled={disabled}
|
||||
type="submit"
|
||||
variant="contained"
|
||||
>
|
||||
Добавить
|
||||
</Button>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
|
||||
export default AddCashForm;
|
||||
@ -1,2 +0,0 @@
|
||||
export type { DeleteCashFormProps } from './ui/DeleteCashForm.ui';
|
||||
export { default as DeleteCashForm } from './ui/DeleteCashForm.ui';
|
||||
@ -1,19 +0,0 @@
|
||||
import { DeleteCashFormHelper } from '@tests/helpers';
|
||||
|
||||
describe('Test DeleteCashForm', () => {
|
||||
test('should render form', () => {
|
||||
DeleteCashFormHelper.renderForm();
|
||||
const { formElem } = DeleteCashFormHelper.getFormElements();
|
||||
expect(formElem).toBeInTheDocument();
|
||||
expect(formElem).toBeVisible();
|
||||
});
|
||||
|
||||
test('should call onSubmit with values', async () => {
|
||||
const { onSubmit } = DeleteCashFormHelper.renderForm();
|
||||
|
||||
await DeleteCashFormHelper.submitForm();
|
||||
|
||||
expect(onSubmit).toHaveBeenCalledTimes(1);
|
||||
expect(onSubmit).toHaveBeenCalledWith(DeleteCashFormHelper.cash);
|
||||
});
|
||||
});
|
||||
@ -1,75 +0,0 @@
|
||||
import type {
|
||||
FC,
|
||||
FormEventHandler,
|
||||
FormHTMLAttributes,
|
||||
RefObject,
|
||||
} from 'react';
|
||||
|
||||
import { Button, Typography } from '@mui/material';
|
||||
|
||||
import { DataTestId } from '@/shared/constants';
|
||||
|
||||
import type { CashItem } from '../../../types';
|
||||
|
||||
export type DeleteCashFormProps = BaseFormProps & {
|
||||
cash: CashItem;
|
||||
disabled?: boolean;
|
||||
onSubmit?: (cash: CashItem) => Promise<void> | void;
|
||||
ref?: RefObject<HTMLFormElement>;
|
||||
};
|
||||
|
||||
type BaseFormProps = Omit<FormHTMLAttributes<HTMLFormElement>, 'onSubmit'>;
|
||||
|
||||
const DeleteCashForm: FC<DeleteCashFormProps> = (props) => {
|
||||
const {
|
||||
cash,
|
||||
disabled,
|
||||
onSubmit,
|
||||
ref,
|
||||
...restProps
|
||||
} = props;
|
||||
|
||||
const onFormSubmitHandler: FormEventHandler<HTMLFormElement> = (event) => {
|
||||
event.preventDefault();
|
||||
onSubmit?.(cash);
|
||||
};
|
||||
|
||||
return (
|
||||
<form
|
||||
data-testid={DataTestId.DELETE_CASH_FORM}
|
||||
onSubmit={onFormSubmitHandler}
|
||||
ref={ref}
|
||||
{...restProps}
|
||||
>
|
||||
<Typography variant="h6">
|
||||
Удалить наличные
|
||||
</Typography>
|
||||
|
||||
<Typography variant="body1">
|
||||
Вы действительно хотите удалить наличные
|
||||
{' '}
|
||||
|
||||
<b>
|
||||
{`"${cash.name}"`}
|
||||
</b>
|
||||
|
||||
{' '}
|
||||
?
|
||||
</Typography>
|
||||
|
||||
<Button
|
||||
color="error"
|
||||
data-testid={DataTestId.DELETE_CASH_SUBMIT_BUTTON}
|
||||
disabled={disabled}
|
||||
fullWidth
|
||||
sx={{ mt: 2 }}
|
||||
type="submit"
|
||||
variant="contained"
|
||||
>
|
||||
Удалить
|
||||
</Button>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
|
||||
export default DeleteCashForm;
|
||||
@ -1,2 +0,0 @@
|
||||
export type { EditCashFormProps, EditCashFormValues } from './ui/EditCashForm.ui';
|
||||
export { default as EditCashForm } from './ui/EditCashForm.ui';
|
||||
@ -1,5 +0,0 @@
|
||||
.form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
@ -1,35 +0,0 @@
|
||||
import { EditCashFormHelper } from '@tests/helpers';
|
||||
|
||||
describe('Test EditCashForm', () => {
|
||||
test('should render form', () => {
|
||||
EditCashFormHelper.renderForm();
|
||||
const { formElem } = EditCashFormHelper.getFormElements();
|
||||
expect(formElem).toBeInTheDocument();
|
||||
expect(formElem).toBeVisible();
|
||||
});
|
||||
|
||||
test('should inputs have default value', () => {
|
||||
EditCashFormHelper.renderForm();
|
||||
const { balanceInputElem, nameInputElem } = EditCashFormHelper.getFormElements();
|
||||
expect(nameInputElem).toHaveValue('Аренда квартиры');
|
||||
expect(balanceInputElem).toHaveValue('41300');
|
||||
});
|
||||
|
||||
test.each([
|
||||
['enabled', {}, (el: HTMLElement) => { expect(el).toBeEnabled(); }],
|
||||
['disabled', { disabled: true }, (el: HTMLElement) => { expect(el).toBeDisabled(); }],
|
||||
])('should render fields %s', (_state, options, assertion) => {
|
||||
EditCashFormHelper.renderForm(options);
|
||||
const { formElem, ...formElems } = EditCashFormHelper.getFormElements();
|
||||
Object.values(formElems).forEach(assertion);
|
||||
});
|
||||
|
||||
test('should call onSubmit with values', async () => {
|
||||
const { onSubmit } = EditCashFormHelper.renderForm();
|
||||
|
||||
const data = { balance: 5000, name: 'Новое название наличных' };
|
||||
await EditCashFormHelper.fillForm(data);
|
||||
expect(onSubmit).toHaveBeenCalledTimes(1);
|
||||
expect(onSubmit).toHaveBeenCalledWith(data);
|
||||
});
|
||||
});
|
||||
@ -1,97 +0,0 @@
|
||||
import type {
|
||||
FC,
|
||||
FormEventHandler,
|
||||
FormHTMLAttributes,
|
||||
RefObject,
|
||||
} from 'react';
|
||||
|
||||
import { Button, TextField, Typography } from '@mui/material';
|
||||
|
||||
import type { CashItem } from '@/entity/cash';
|
||||
|
||||
import { DataTestId } from '@/shared/constants';
|
||||
|
||||
import classes from './EditCashForm.module.css';
|
||||
|
||||
export type EditCashFormProps = BaseFormProps & {
|
||||
cash: CashItem;
|
||||
disabled?: boolean;
|
||||
onSubmit?: (data: EditCashFormValues) => Promise<void> | void;
|
||||
ref?: RefObject<HTMLFormElement>;
|
||||
};
|
||||
|
||||
export type EditCashFormValues = {
|
||||
balance: number;
|
||||
name: string;
|
||||
};
|
||||
|
||||
type BaseFormProps = Omit<FormHTMLAttributes<HTMLFormElement>, 'onSubmit'>;
|
||||
|
||||
const EditCashForm: FC<EditCashFormProps> = (props) => {
|
||||
const {
|
||||
cash,
|
||||
disabled,
|
||||
onSubmit,
|
||||
ref,
|
||||
...restProps
|
||||
} = props;
|
||||
|
||||
const onFormSubmitHandler: FormEventHandler<HTMLFormElement> = (event) => {
|
||||
event.preventDefault();
|
||||
|
||||
const formData = new FormData(event.currentTarget);
|
||||
|
||||
const name = formData.get('name');
|
||||
const balance = formData.get('balance');
|
||||
|
||||
if (typeof name === 'string' && typeof balance === 'string') {
|
||||
onSubmit?.({
|
||||
balance: Number(balance),
|
||||
name,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<form
|
||||
className={classes.form}
|
||||
data-testid={DataTestId.EDIT_CASH_FORM}
|
||||
onSubmit={onFormSubmitHandler}
|
||||
ref={ref}
|
||||
{...restProps}
|
||||
>
|
||||
<Typography variant="h6">
|
||||
Редактировать наличные
|
||||
</Typography>
|
||||
|
||||
<TextField
|
||||
defaultValue={cash.name}
|
||||
disabled={disabled}
|
||||
label="Название"
|
||||
name="name"
|
||||
required
|
||||
size="small"
|
||||
/>
|
||||
|
||||
<TextField
|
||||
defaultValue={cash.balance}
|
||||
disabled={disabled}
|
||||
inputMode="numeric"
|
||||
label="Баланс"
|
||||
name="balance"
|
||||
size="small"
|
||||
/>
|
||||
|
||||
<Button
|
||||
data-testid={DataTestId.EDIT_CASH_SUBMIT_BUTTON}
|
||||
disabled={disabled}
|
||||
type="submit"
|
||||
variant="contained"
|
||||
>
|
||||
Сохранить
|
||||
</Button>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
|
||||
export default EditCashForm;
|
||||
@ -1,13 +0,0 @@
|
||||
import type { paths } from '@/shared/api/schema';
|
||||
|
||||
import { api } from '@/shared/api/client';
|
||||
|
||||
export type AddExpenseItemBody = paths['/expenses/list/{id}/items']['post']['requestBody']['content']['application/json'];
|
||||
export type AddExpenseItemPath = paths['/expenses/list/{id}/items']['post']['parameters']['path'];
|
||||
export type AddExpenseItemSuccessResponse = paths['/expenses/list/{id}/items']['post']['responses']['200']['content']['application/json'];
|
||||
|
||||
export async function addExpenseItem(path: AddExpenseItemPath, data: AddExpenseItemBody) {
|
||||
const { data: result } = await api.post<AddExpenseItemSuccessResponse>(`/expenses/list/${path.id}/items`, data);
|
||||
|
||||
return result;
|
||||
}
|
||||
@ -1,12 +0,0 @@
|
||||
import type { paths } from '@/shared/api/schema';
|
||||
|
||||
import { api } from '@/shared/api/client';
|
||||
|
||||
export type DeleteExpenseItemPath = paths['/expenses/list/{listId}/items/{id}']['delete']['parameters']['path'];
|
||||
export type DeleteExpenseItemSuccessResponse = paths['/expenses/list/{id}/items']['post']['responses']['200']['content']['application/json'];
|
||||
|
||||
export async function deleteExpenseItem(path: DeleteExpenseItemPath) {
|
||||
const { data: result } = await api.delete<DeleteExpenseItemSuccessResponse>(`/expenses/list/${path.listId}/items/${path.id}`);
|
||||
|
||||
return result;
|
||||
}
|
||||
@ -1,13 +0,0 @@
|
||||
import type { paths } from '@/shared/api/schema';
|
||||
|
||||
import { api } from '@/shared/api/client';
|
||||
|
||||
export type EditExpenseItemBody = paths['/expenses/list/{listId}/items/{id}']['patch']['requestBody']['content']['application/json'];
|
||||
export type EditExpenseItemPath = paths['/expenses/list/{listId}/items/{id}']['patch']['parameters']['path'];
|
||||
export type EditExpenseItemSuccessResponse = paths['/expenses/list/{listId}/items/{id}']['patch']['responses']['200']['content']['application/json'];
|
||||
|
||||
export async function editExpenseItem(path: EditExpenseItemPath, data: EditExpenseItemBody) {
|
||||
const { data: result } = await api.patch<EditExpenseItemSuccessResponse>(`/expenses/list/${path.listId}/items/${path.id}`, data);
|
||||
|
||||
return result;
|
||||
}
|
||||
@ -1,17 +0,0 @@
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
|
||||
import { QueryKeys } from '@/shared/api/queryKeys';
|
||||
|
||||
import { addExpenseItem, type AddExpenseItemBody, type AddExpenseItemPath } from '../api/addExpenseItem';
|
||||
|
||||
export const useAddExpenseItem = (path: AddExpenseItemPath) => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async (data: AddExpenseItemBody) => addExpenseItem(path, data),
|
||||
mutationKey: [QueryKeys.ExpenseList, path.id],
|
||||
onSuccess: async () => {
|
||||
await queryClient.invalidateQueries({ queryKey: [QueryKeys.ExpenseList, path.id] });
|
||||
},
|
||||
});
|
||||
};
|
||||
@ -1,19 +0,0 @@
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
|
||||
import { QueryKeys } from '@/shared/api/queryKeys';
|
||||
|
||||
import type { DeleteExpenseItemPath } from '../api/deleteExpenseItem';
|
||||
|
||||
import { deleteExpenseItem } from '../api/deleteExpenseItem';
|
||||
|
||||
export const useDeleteExpenseItem = (path: DeleteExpenseItemPath) => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async () => deleteExpenseItem(path),
|
||||
mutationKey: [QueryKeys.ExpenseList, path.listId],
|
||||
onSuccess: async () => {
|
||||
await queryClient.invalidateQueries({ queryKey: [QueryKeys.ExpenseList, path.listId] });
|
||||
},
|
||||
});
|
||||
};
|
||||
@ -1,17 +0,0 @@
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
|
||||
import { QueryKeys } from '@/shared/api/queryKeys';
|
||||
|
||||
import { editExpenseItem, type EditExpenseItemBody, type EditExpenseItemPath } from '../api/editExpenseItem';
|
||||
|
||||
export const useEditExpenseItem = (path: EditExpenseItemPath) => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async (data: EditExpenseItemBody) => editExpenseItem(path, data),
|
||||
mutationKey: [QueryKeys.ExpenseList, path.listId],
|
||||
onSuccess: async () => {
|
||||
await queryClient.invalidateQueries({ queryKey: [QueryKeys.ExpenseList, path.listId] });
|
||||
},
|
||||
});
|
||||
};
|
||||
@ -1,18 +0,0 @@
|
||||
export type { AddExpenseItemBody, AddExpenseItemPath, AddExpenseItemSuccessResponse } from './api/addExpenseItem';
|
||||
export type { DeleteExpenseItemPath, DeleteExpenseItemSuccessResponse } from './api/deleteExpenseItem';
|
||||
export type { EditExpenseItemBody, EditExpenseItemPath, EditExpenseItemSuccessResponse } from './api/editExpenseItem';
|
||||
|
||||
export { useAddExpenseItem } from './hooks/useAddExpenseItem';
|
||||
export { useDeleteExpenseItem } from './hooks/useDeleteExpenseItem';
|
||||
export { useEditExpenseItem } from './hooks/useEditExpenseItem';
|
||||
|
||||
export type { ExpenseListItem } from './types';
|
||||
|
||||
export type { AddExpenseItemFormProps, AddExpenseItemFormValues } from './ui/AddExpenseItemForm/AddExpenseItemForm.ui';
|
||||
export { default as AddExpenseItemForm } from './ui/AddExpenseItemForm/AddExpenseItemForm.ui';
|
||||
|
||||
export type { DeleteExpenseItemFormProps } from './ui/DeleteExpenseItemForm/DeleteExpenseItemForm.ui';
|
||||
export { default as DeleteExpenseItemForm } from './ui/DeleteExpenseItemForm/DeleteExpenseItemForm.ui';
|
||||
|
||||
export type { EditExpenseItemFormProps, EditExpenseItemFormValues } from './ui/EditExpenseItemForm/EditExpenseItemForm.ui';
|
||||
export { default as EditExpenseItemForm } from './ui/EditExpenseItemForm/EditExpenseItemForm.ui';
|
||||
@ -1,3 +0,0 @@
|
||||
import type { paths } from '@/shared/api/schema';
|
||||
|
||||
export type ExpenseListItem = NonNullable<paths['/expenses/list/{id}']['get']['responses']['200']['content']['application/json']['items']>[number];
|
||||
@ -1,5 +0,0 @@
|
||||
.form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
@ -1,121 +0,0 @@
|
||||
import { bankCardList, cashList } from '@mocks/browser/data';
|
||||
import { within } from '@testing-library/react';
|
||||
import { AddExpenseItemFormHelper } from '@tests/helpers';
|
||||
|
||||
describe('Test AddExpenseItemForm', () => {
|
||||
test('should render form', () => {
|
||||
AddExpenseItemFormHelper.renderForm();
|
||||
const { formElem } = AddExpenseItemFormHelper.getFormElements();
|
||||
expect(formElem).toBeInTheDocument();
|
||||
expect(formElem).toBeVisible();
|
||||
});
|
||||
|
||||
test.each([
|
||||
['enabled', {}, (el: HTMLElement) => { expect(el).toBeEnabled(); }],
|
||||
['disabled', { disabled: true }, (el: HTMLElement) => { expect(el).toBeDisabled(); }],
|
||||
])('should render fields %s', (_state, options, assertion) => {
|
||||
AddExpenseItemFormHelper.renderForm(options);
|
||||
const {
|
||||
bankCardSelect,
|
||||
bankCardSelectInput,
|
||||
cashSelect,
|
||||
cashSelectInput,
|
||||
formElem,
|
||||
paymentTypeSelect,
|
||||
...formElems
|
||||
} = AddExpenseItemFormHelper.getFormElements();
|
||||
[
|
||||
...Object.values(formElems),
|
||||
within(paymentTypeSelect).getByTestId('payment-type-select-input'),
|
||||
].forEach(assertion);
|
||||
});
|
||||
|
||||
describe('should call onSubmit with values', () => {
|
||||
const setup = async () => {
|
||||
const { onSubmit } = AddExpenseItemFormHelper.renderForm();
|
||||
|
||||
await AddExpenseItemFormHelper.fillForm({
|
||||
amount: '12',
|
||||
count: '1',
|
||||
date: '29.08.2025',
|
||||
description: 'Описание элемента',
|
||||
title: 'Название элемента',
|
||||
}, { autoSubmit: false });
|
||||
|
||||
return {
|
||||
onSubmit,
|
||||
};
|
||||
};
|
||||
|
||||
test('no payment', async () => {
|
||||
const { onSubmit } = await setup();
|
||||
await AddExpenseItemFormHelper.submitForm();
|
||||
|
||||
expect(onSubmit).toHaveBeenCalledTimes(1);
|
||||
expect(onSubmit).toHaveBeenCalledWith({
|
||||
amount: 12,
|
||||
count: 1,
|
||||
currency: 'RUB',
|
||||
date: '2025-08-29T00:00:00.000Z',
|
||||
description: 'Описание элемента',
|
||||
payment: null,
|
||||
title: 'Название элемента',
|
||||
});
|
||||
});
|
||||
|
||||
test('bank card payment', async () => {
|
||||
const { onSubmit } = await setup();
|
||||
await AddExpenseItemFormHelper.fillForm({ paymentType: 'BANK_CARD' }, { autoSubmit: false });
|
||||
|
||||
const { bankCardSelect, bankCardSelectInput } = AddExpenseItemFormHelper.getFormElements();
|
||||
expect(bankCardSelect).toBeVisible();
|
||||
expect(bankCardSelectInput).toBeEnabled();
|
||||
expect(bankCardSelectInput).toHaveValue(bankCardList[0].id);
|
||||
|
||||
await AddExpenseItemFormHelper.fillForm({ paymentId: bankCardList[1].id, paymentType: 'BANK_CARD' }, { autoSubmit: false });
|
||||
expect(bankCardSelectInput).toHaveValue(bankCardList[1].id);
|
||||
|
||||
await AddExpenseItemFormHelper.submitForm();
|
||||
|
||||
expect(onSubmit).toHaveBeenCalledTimes(1);
|
||||
expect(onSubmit).toHaveBeenCalledWith({
|
||||
amount: 12,
|
||||
count: 1,
|
||||
currency: 'RUB',
|
||||
date: '2025-08-29T00:00:00.000Z',
|
||||
description: 'Описание элемента',
|
||||
payment: {
|
||||
id: bankCardList[1].id,
|
||||
type: 'BANK_CARD',
|
||||
},
|
||||
title: 'Название элемента',
|
||||
});
|
||||
});
|
||||
|
||||
test('cash payment', async () => {
|
||||
const { onSubmit } = await setup();
|
||||
|
||||
await AddExpenseItemFormHelper.fillForm({ paymentType: 'CASH' });
|
||||
const { cashSelect, cashSelectInput } = AddExpenseItemFormHelper.getFormElements();
|
||||
expect(cashSelect).toBeVisible();
|
||||
expect(cashSelectInput).toBeEnabled();
|
||||
expect(cashSelectInput).toHaveValue(cashList[0].id);
|
||||
|
||||
await AddExpenseItemFormHelper.fillForm({ paymentId: cashList[1].id, paymentType: 'CASH' });
|
||||
|
||||
expect(onSubmit).toHaveBeenCalledTimes(1);
|
||||
expect(onSubmit).toHaveBeenCalledWith({
|
||||
amount: 12,
|
||||
count: 1,
|
||||
currency: 'RUB',
|
||||
date: '2025-08-29T00:00:00.000Z',
|
||||
description: 'Описание элемента',
|
||||
payment: {
|
||||
id: cashList[1].id,
|
||||
type: 'CASH',
|
||||
},
|
||||
title: 'Название элемента',
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -1,273 +0,0 @@
|
||||
import type { SelectChangeEvent } from '@mui/material';
|
||||
import type {
|
||||
FC,
|
||||
FormEventHandler,
|
||||
FormHTMLAttributes,
|
||||
RefObject,
|
||||
} from 'react';
|
||||
|
||||
import {
|
||||
Button,
|
||||
FormControl,
|
||||
InputLabel,
|
||||
MenuItem,
|
||||
Select,
|
||||
TextField,
|
||||
Typography,
|
||||
} from '@mui/material';
|
||||
import { DatePicker } from '@mui/x-date-pickers';
|
||||
import {
|
||||
useMemo,
|
||||
useState,
|
||||
} from 'react';
|
||||
|
||||
import type { ExpenseListItem } from '@/entity/expenses/item';
|
||||
|
||||
import { useBankCards } from '@/entity/cards';
|
||||
import { useCash } from '@/entity/cash';
|
||||
import { DataTestId } from '@/shared/constants';
|
||||
import { formatRub, getUTCDate } from '@/shared/utils';
|
||||
|
||||
import classes from './AddExpenseItemForm.module.css';
|
||||
|
||||
export type AddExpenseItemFormProps = BaseFormProps & {
|
||||
disabled?: boolean;
|
||||
onSubmit?: (data: AddExpenseItemFormValues) => Promise<void> | void;
|
||||
ref?: RefObject<HTMLFormElement>;
|
||||
};
|
||||
|
||||
export type AddExpenseItemFormValues = {
|
||||
amount: number;
|
||||
count: number;
|
||||
currency: string;
|
||||
date: string;
|
||||
description: string;
|
||||
payment: null | {
|
||||
id: string;
|
||||
type: 'BANK_CARD' | 'CASH';
|
||||
};
|
||||
title: string;
|
||||
};
|
||||
|
||||
type BaseFormProps = Omit<FormHTMLAttributes<HTMLFormElement>, 'onSubmit'>;
|
||||
|
||||
const AddExpenseItemForm: FC<AddExpenseItemFormProps> = (props) => {
|
||||
const {
|
||||
disabled,
|
||||
onSubmit,
|
||||
ref,
|
||||
...formProps
|
||||
} = props;
|
||||
|
||||
const [paymentType, setPaymentType] = useState<NonNullable<ExpenseListItem['payment']>['type']>(null);
|
||||
|
||||
const onFormSubmitHandler: FormEventHandler<HTMLFormElement> = (event) => {
|
||||
event.preventDefault();
|
||||
const formData = new FormData(event.currentTarget);
|
||||
|
||||
const title = formData.get('title');
|
||||
const date = formData.get('date');
|
||||
const amount = formData.get('amount');
|
||||
const description = formData.get('description');
|
||||
const count = formData.get('count');
|
||||
const paymentMethod = formData.get('paymentMethod');
|
||||
const bankCardId = formData.get('bank-card');
|
||||
const cashId = formData.get('cash');
|
||||
|
||||
const getPayment = () => {
|
||||
switch (paymentMethod) {
|
||||
case 'BANK_CARD':
|
||||
return {
|
||||
id: bankCardId,
|
||||
type: 'BANK_CARD',
|
||||
};
|
||||
case 'CASH':
|
||||
return {
|
||||
id: cashId,
|
||||
type: 'CASH',
|
||||
};
|
||||
case 'none':
|
||||
case null:
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
if (typeof title === 'string' && typeof date === 'string' && typeof amount === 'string' && typeof description === 'string' && typeof count === 'string') {
|
||||
onSubmit?.({
|
||||
amount: Number(amount),
|
||||
count: Number(count),
|
||||
currency: 'RUB',
|
||||
date: getUTCDate(date),
|
||||
description,
|
||||
// @ts-ignore todo
|
||||
payment: getPayment(),
|
||||
title,
|
||||
});
|
||||
}
|
||||
};
|
||||
const { data: bankCards } = useBankCards();
|
||||
const { data: cash } = useCash();
|
||||
|
||||
const bankCardOptions = bankCards?.map((card) => (
|
||||
<MenuItem key={card.id} value={card.id}>
|
||||
{`${card.name} (${formatRub(card.balance)})`}
|
||||
</MenuItem>
|
||||
));
|
||||
|
||||
const cashOptions = cash?.map((cashItem) => (
|
||||
<MenuItem key={cashItem.id} value={cashItem.id}>
|
||||
{`${cashItem.name} (${formatRub(cashItem.balance)})`}
|
||||
</MenuItem>
|
||||
));
|
||||
|
||||
const paymentOptions = useMemo(() => {
|
||||
const options = [<MenuItem key="none" value="none">Без оплаты</MenuItem>];
|
||||
|
||||
if (bankCardOptions && bankCardOptions.length > 0) {
|
||||
options.push(<MenuItem key="BANK_CARD" value="BANK_CARD">Банковская карта</MenuItem>);
|
||||
}
|
||||
|
||||
if (cashOptions && cashOptions.length > 0) {
|
||||
options.push(<MenuItem key="CASH" value="CASH">Наличные</MenuItem>);
|
||||
}
|
||||
|
||||
return options;
|
||||
}, [bankCardOptions, cashOptions]);
|
||||
|
||||
const onChangePaymentHandler = (event: SelectChangeEvent<'BANK_CARD' | 'CASH' | 'none'>) => {
|
||||
const { value } = event.target;
|
||||
if (value === 'none') {
|
||||
setPaymentType(null);
|
||||
} else {
|
||||
// @ts-ignore todo
|
||||
setPaymentType(value);
|
||||
}
|
||||
};
|
||||
|
||||
const renderCardSelect = () => (
|
||||
<FormControl fullWidth>
|
||||
<InputLabel id="bank-card-label">Карта</InputLabel>
|
||||
|
||||
<Select
|
||||
data-testid="bank-card-select"
|
||||
defaultValue={bankCards?.[0].id}
|
||||
disabled={disabled}
|
||||
inputProps={{ 'data-testid': 'bank-card-select-input' }}
|
||||
labelId="bank-card-label"
|
||||
name="bank-card"
|
||||
size="small"
|
||||
>
|
||||
{bankCardOptions}
|
||||
</Select>
|
||||
</FormControl>
|
||||
);
|
||||
|
||||
const renderCashSelect = () => (
|
||||
<FormControl fullWidth>
|
||||
<InputLabel id="cash-label">Наличные</InputLabel>
|
||||
|
||||
<Select
|
||||
data-testid="cash-select"
|
||||
defaultValue={cash?.[0].id}
|
||||
disabled={disabled}
|
||||
inputProps={{ 'data-testid': 'cash-select-input' }}
|
||||
labelId="cash-label"
|
||||
name="cash"
|
||||
size="small"
|
||||
>
|
||||
{cashOptions ?? []}
|
||||
</Select>
|
||||
</FormControl>
|
||||
);
|
||||
|
||||
return (
|
||||
<form
|
||||
className={classes.form}
|
||||
data-testid={DataTestId.ADD_EXPENSE_ITEM_FORM}
|
||||
onSubmit={onFormSubmitHandler}
|
||||
ref={ref}
|
||||
{...formProps}
|
||||
>
|
||||
<Typography variant="h6">
|
||||
Добавить список расходов
|
||||
</Typography>
|
||||
|
||||
<TextField
|
||||
disabled={disabled}
|
||||
label="Название"
|
||||
name="title"
|
||||
required
|
||||
size="small"
|
||||
/>
|
||||
|
||||
<TextField
|
||||
disabled={disabled}
|
||||
label="Описание"
|
||||
name="description"
|
||||
size="small"
|
||||
/>
|
||||
|
||||
<DatePicker
|
||||
defaultValue={new Date()}
|
||||
disabled={disabled}
|
||||
label="Дата"
|
||||
name="date"
|
||||
slotProps={{ textField: { required: true, size: 'small' } }}
|
||||
/>
|
||||
|
||||
<TextField
|
||||
disabled={disabled}
|
||||
inputMode="numeric"
|
||||
label="Сумма"
|
||||
name="amount"
|
||||
required
|
||||
size="small"
|
||||
type="number"
|
||||
/>
|
||||
|
||||
<TextField
|
||||
defaultValue={1}
|
||||
disabled={disabled}
|
||||
inputMode="numeric"
|
||||
label="Количество"
|
||||
name="count"
|
||||
size="small"
|
||||
type="number"
|
||||
/>
|
||||
|
||||
<FormControl fullWidth>
|
||||
<InputLabel id="payment-type-label">Способ оплаты</InputLabel>
|
||||
|
||||
<Select
|
||||
data-testid="payment-type-select"
|
||||
defaultValue="none"
|
||||
disabled={disabled}
|
||||
inputProps={{ 'data-testid': 'payment-type-select-input' }}
|
||||
labelId="payment-type-label"
|
||||
name="paymentMethod"
|
||||
onChange={onChangePaymentHandler}
|
||||
size="small"
|
||||
>
|
||||
{paymentOptions}
|
||||
</Select>
|
||||
</FormControl>
|
||||
|
||||
{paymentType === 'BANK_CARD' && renderCardSelect()}
|
||||
|
||||
{paymentType === 'CASH' && renderCashSelect()}
|
||||
|
||||
<Button
|
||||
data-testid={DataTestId.ADD_EXPENSE_ITEM_SUBMIT_BUTTON}
|
||||
disabled={disabled}
|
||||
size="small"
|
||||
type="submit"
|
||||
variant="contained"
|
||||
>
|
||||
Добавить
|
||||
</Button>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
|
||||
export default AddExpenseItemForm;
|
||||
@ -1,18 +0,0 @@
|
||||
import { DeleteExpenseItemFormHelper } from '@tests/helpers';
|
||||
|
||||
describe('Test DeleteExpenseItemForm', () => {
|
||||
test('should render form', () => {
|
||||
DeleteExpenseItemFormHelper.renderForm();
|
||||
const { formElem } = DeleteExpenseItemFormHelper.getFormElements();
|
||||
expect(formElem).toBeInTheDocument();
|
||||
expect(formElem).toBeVisible();
|
||||
});
|
||||
|
||||
test('should call onSubmit with values', async () => {
|
||||
const { onSubmit } = DeleteExpenseItemFormHelper.renderForm();
|
||||
await DeleteExpenseItemFormHelper.submitForm();
|
||||
|
||||
expect(onSubmit).toHaveBeenCalledTimes(1);
|
||||
expect(onSubmit).toHaveBeenCalledWith(DeleteExpenseItemFormHelper.expenseItem);
|
||||
});
|
||||
});
|
||||
@ -1,62 +0,0 @@
|
||||
import type {
|
||||
FC,
|
||||
FormEventHandler,
|
||||
FormHTMLAttributes,
|
||||
RefObject,
|
||||
} from 'react';
|
||||
|
||||
import { Button, Typography } from '@mui/material';
|
||||
|
||||
import { DataTestId } from '@/shared/constants';
|
||||
|
||||
import type { ExpenseListItem } from '../../types';
|
||||
|
||||
export type DeleteExpenseItemFormProps = BaseFormProps & {
|
||||
disabled?: boolean;
|
||||
item: ExpenseListItem;
|
||||
onSubmit?: (item: ExpenseListItem) => Promise<void> | void;
|
||||
ref?: RefObject<HTMLFormElement>;
|
||||
};
|
||||
|
||||
type BaseFormProps = Omit<FormHTMLAttributes<HTMLFormElement>, 'onSubmit'>;
|
||||
|
||||
const DeleteExpenseItemForm: FC<DeleteExpenseItemFormProps> = (props) => {
|
||||
const {
|
||||
disabled,
|
||||
item,
|
||||
onSubmit,
|
||||
ref,
|
||||
...restProps
|
||||
} = props;
|
||||
const onFormSubmitHandler: FormEventHandler<HTMLFormElement> = (event) => {
|
||||
event.preventDefault();
|
||||
onSubmit?.(item);
|
||||
};
|
||||
|
||||
return (
|
||||
<form
|
||||
data-testid={DataTestId.DELETE_EXPENSE_ITEM_FORM}
|
||||
onSubmit={onFormSubmitHandler}
|
||||
ref={ref}
|
||||
{...restProps}
|
||||
>
|
||||
<Typography variant="h6">
|
||||
Удалить элемент ?
|
||||
</Typography>
|
||||
|
||||
<Button
|
||||
color="error"
|
||||
data-testid={DataTestId.DELETE_EXPENSE_ITEM_SUBMIT_BUTTON}
|
||||
disabled={disabled}
|
||||
fullWidth
|
||||
sx={{ mt: 2 }}
|
||||
type="submit"
|
||||
variant="contained"
|
||||
>
|
||||
Удалить
|
||||
</Button>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
|
||||
export default DeleteExpenseItemForm;
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user