feat(frontend): add income

This commit is contained in:
Sergey Krylov 2025-09-16 05:59:39 +03:00
parent ee6276e8ab
commit 9a7d444d9d
77 changed files with 3088 additions and 1 deletions

View File

@ -6,6 +6,8 @@ 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';
@ -19,6 +21,10 @@ const Routing = () => (
<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} />

View File

@ -0,0 +1,13 @@
import { api } from '@/shared/api/client';
import type { paths } from '@/shared/api/schema';
export type AddIncomeItemBody = paths['/incomes/list/{id}/items']['post']['requestBody']['content']['application/json'];
export type AddIncomeItemPath = paths['/incomes/list/{id}/items']['post']['parameters']['path'];
export type AddIncomeItemSuccessResponse = paths['/incomes/list/{id}/items']['post']['responses']['200']['content']['application/json'];
export async function addIncomeItem(path: AddIncomeItemPath, data: AddIncomeItemBody) {
const { data: result } = await api.post<AddIncomeItemSuccessResponse>(`/incomes/list/${path.id}/items`, data);
return result;
}

View File

@ -0,0 +1,12 @@
import { api } from '@/shared/api/client';
import type { paths } from '@/shared/api/schema';
export type DeleteIncomeItemPath = paths['/incomes/list/{listId}/items/{id}']['delete']['parameters']['path'];
export type DeleteIncomeItemSuccessResponse = paths['/incomes/list/{id}/items']['post']['responses']['200']['content']['application/json'];
export async function deleteIncomeItem(path: DeleteIncomeItemPath) {
const { data: result } = await api.delete<DeleteIncomeItemSuccessResponse>(`/incomes/list/${path.listId}/items/${path.id}`);
return result;
}

View File

@ -0,0 +1,13 @@
import { api } from '@/shared/api/client';
import type { paths } from '@/shared/api/schema';
export type EditIncomeItemBody = paths['/incomes/list/{listId}/items/{id}']['patch']['requestBody']['content']['application/json'];
export type EditIncomeItemPath = paths['/incomes/list/{listId}/items/{id}']['patch']['parameters']['path'];
export type EditIncomeItemSuccessResponse = paths['/incomes/list/{listId}/items/{id}']['patch']['responses']['200']['content']['application/json'];
export async function editIncomeItem(path: EditIncomeItemPath, data: EditIncomeItemBody) {
const { data: result } = await api.patch<EditIncomeItemSuccessResponse>(`/incomes/list/${path.listId}/items/${path.id}`, data);
return result;
}

View File

@ -0,0 +1,17 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { QueryKeys } from '@/shared/api/queryKeys';
import { addIncomeItem, type AddIncomeItemBody, type AddIncomeItemPath } from '../api/addIncomeItem';
export const useAddIncomeItem = (path: AddIncomeItemPath) => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (data: AddIncomeItemBody) => addIncomeItem(path, data),
mutationKey: [QueryKeys.IncomesList, path.id],
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: [QueryKeys.IncomesList, path.id] });
},
});
};

View File

@ -0,0 +1,19 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { QueryKeys } from '@/shared/api/queryKeys';
import { deleteIncomeItem } from '../api/deleteIncomeItem';
import type { DeleteIncomeItemPath } from '../api/deleteIncomeItem';
export const useDeleteIncomeItem = (path: DeleteIncomeItemPath) => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async () => deleteIncomeItem(path),
mutationKey: [QueryKeys.IncomesList, path.listId],
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: [QueryKeys.IncomesList, path.listId] });
},
});
};

View File

@ -0,0 +1,17 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { QueryKeys } from '@/shared/api/queryKeys';
import { editIncomeItem, type EditIncomeItemBody, type EditIncomeItemPath } from '../api/editIncomeItem';
export const useEditIncomeItem = (path: EditIncomeItemPath) => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (data: EditIncomeItemBody) => editIncomeItem(path, data),
mutationKey: [QueryKeys.IncomesList, path.listId],
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: [QueryKeys.IncomesList, path.listId] });
},
});
};

View File

@ -0,0 +1,17 @@
export { useAddIncomeItem } from './hooks/useAddIncomeItem';
export { useDeleteIncomeItem } from './hooks/useDeleteIncomeItem';
export { useEditIncomeItem } from './hooks/useEditIncomeItem';
export type { AddIncomeItemPath, AddIncomeItemBody, AddIncomeItemSuccessResponse } from './api/addIncomeItem';
export type { DeleteIncomeItemPath, DeleteIncomeItemSuccessResponse } from './api/deleteIncomeItem';
export type { EditIncomeItemPath, EditIncomeItemBody, EditIncomeItemSuccessResponse } from './api/editIncomeItem';
export type { AddIncomeItemFormValues } from './ui/AddIncomeItemForm/AddIncomeItemForm.ui';
export { default as AddIncomeItemForm } from './ui/AddIncomeItemForm/AddIncomeItemForm.ui';
export type { EditIncomeItemFormValues } from './ui/EditIncomeItemForm/EditIncomeItemForm.ui';
export { default as EditIncomeItemForm } from './ui/EditIncomeItemForm/EditIncomeItemForm.ui';
export { default as DeleteIncomeItemForm } from './ui/DeleteIncomeItemForm/DeleteIncomeItemForm.ui';
export type { IncomeListItem } from './types';

View File

@ -0,0 +1,3 @@
import type { paths } from '@/shared/api/schema';
export type IncomeListItem = NonNullable<paths['/incomes/list/{id}']['get']['responses']['200']['content']['application/json']['items']>[number];

View File

@ -0,0 +1,5 @@
.form {
display: flex;
flex-direction: column;
gap: 10px;
}

View File

@ -0,0 +1,75 @@
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import AddIncomeItemForm from './AddIncomeItemForm.ui';
import type { AddIncomeItemFormProps } from './AddIncomeItemForm.ui';
const renderForm = (props?: Partial<AddIncomeItemFormProps>) => {
const onSubmit = jest.fn();
render(<AddIncomeItemForm data-testid="add-income-item-form" onSubmit={onSubmit} {...props} />);
const titleInputElem = screen.getByLabelText('Название');
const descriptionInputElem = screen.getByLabelText('Описание');
const dateInputElem = screen.getByLabelText('Дата');
const amountInputElem = screen.getByLabelText('Сумма');
const submitButtonElem = screen.getByText('Добавить');
return {
titleInputElem,
descriptionInputElem,
dateInputElem,
amountInputElem,
submitButtonElem,
onSubmit,
};
};
describe('Test AddIncomeItemForm', () => {
test('should render form', () => {
renderForm();
const form = screen.getByTestId('add-income-item-form');
expect(form).toBeInTheDocument();
expect(form).toBeVisible();
});
test.each([
['enabled', {}, (el: HTMLElement) => {
expect(el).toBeEnabled();
}],
['disabled', { disabled: true }, (el: HTMLElement) => {
expect(el).toBeDisabled();
}],
])('should render fields %s', (_state, options, assertion) => {
const { onSubmit, ...formElems } = renderForm(options);
Object.values(formElems).forEach(assertion);
});
test('should call onSubmit with values', async () => {
const {
titleInputElem,
descriptionInputElem,
dateInputElem,
amountInputElem,
submitButtonElem,
onSubmit,
} = renderForm();
await userEvent.type(titleInputElem, 'Название элемента');
await userEvent.type(descriptionInputElem, 'Описание элемента');
await userEvent.type(dateInputElem, '2025-08-29');
await userEvent.type(amountInputElem, '12');
await userEvent.click(submitButtonElem);
expect(onSubmit).toHaveBeenCalledTimes(1);
expect(onSubmit).toHaveBeenCalledWith({
title: 'Название элемента',
description: 'Описание элемента',
date: '2025-08-29T00:00:00.000Z',
amount: 12,
currency: 'RUB',
});
});
});

View File

@ -0,0 +1,101 @@
import { Input } from '@/shared/ui/Input';
import classes from './AddIncomeItemForm.module.css';
import type {
FC,
FormEventHandler,
FormHTMLAttributes,
RefObject,
} from 'react';
export type AddIncomeItemFormValues = {
title: string;
description: string;
date: string;
amount: number;
currency: string;
};
type BaseFormProps = Omit<FormHTMLAttributes<HTMLFormElement>, 'onSubmit'>;
export type AddIncomeItemFormProps = BaseFormProps & {
disabled?: boolean;
onSubmit?: (data: AddIncomeItemFormValues) => Promise<void> | void;
ref?: RefObject<HTMLFormElement>;
};
const AddIncomeItemForm: FC<AddIncomeItemFormProps> = (props) => {
const {
disabled,
onSubmit,
ref,
...formProps
} = props;
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');
if (typeof title === 'string' && typeof date === 'string' && typeof amount === 'string' && typeof description === 'string') {
onSubmit?.({
title,
date: new Date(date).toISOString(),
amount: Number(amount),
description,
currency: 'RUB',
});
}
};
return (
<form
ref={ref}
className={classes.form}
onSubmit={onFormSubmitHandler}
{...formProps}
>
<h2>Добавить список доходов</h2>
<Input
required
disabled={disabled}
label="Название"
name="title"
/>
<Input
disabled={disabled}
label="Описание"
name="description"
/>
<Input
required
disabled={disabled}
label="Дата"
name="date"
type="date"
/>
<Input
required
disabled={disabled}
label="Сумма"
name="amount"
step="0.01"
type="number"
/>
<button disabled={disabled} type="submit">Добавить</button>
</form>
);
};
export default AddIncomeItemForm;

View File

@ -0,0 +1,59 @@
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import DeleteIncomeItemForm from './DeleteIncomeItemForm.ui';
import type { DeleteIncomeItemFormProps } from './DeleteIncomeItemForm.ui';
const deletingItem = {
id: '51a44dc0-597b-49b9-85bb-e0bcd42db3f6',
title: 'Mock item',
amount: 12,
updatedAt: '2025-08-29T07:57:40.503Z',
incomeListId: '10cc093c-3875-4d33-a40a-df526266e262',
createdAt: '2025-08-29T07:57:40.503Z',
currency: 'RUB',
date: '2025-08-22T00:00:03.000Z',
description: 'Mock description',
};
const renderForm = (props?: Partial<DeleteIncomeItemFormProps>) => {
const onSubmit = jest.fn();
render(
<DeleteIncomeItemForm
data-testid="delete-income-item-form"
item={deletingItem}
onSubmit={onSubmit}
{...props}
/>,
);
const submitButtonElem = screen.getByText('Удалить');
return {
submitButtonElem,
onSubmit,
};
};
describe('Test DeleteIncomeItemForm', () => {
test('should render form', () => {
renderForm();
const form = screen.getByTestId('delete-income-item-form');
expect(form).toBeInTheDocument();
expect(form).toBeVisible();
});
test('should call onSubmit with values', async () => {
const {
submitButtonElem,
onSubmit,
} = renderForm();
await userEvent.click(submitButtonElem);
expect(onSubmit).toHaveBeenCalledTimes(1);
expect(onSubmit).toHaveBeenCalledWith(deletingItem);
});
});

View File

@ -0,0 +1,40 @@
import type { IncomeListItem } from '../../types';
import type {
FC,
FormEventHandler,
FormHTMLAttributes,
RefObject,
} from 'react';
type BaseFormProps = Omit<FormHTMLAttributes<HTMLFormElement>, 'onSubmit'>;
export type DeleteIncomeItemFormProps = BaseFormProps & {
item: IncomeListItem;
onSubmit?: (item: IncomeListItem) => Promise<void> | void;
disabled?: boolean;
ref?: RefObject<HTMLFormElement>;
};
const DeleteIncomeItemForm: FC<DeleteIncomeItemFormProps> = (props) => {
const {
item,
onSubmit,
ref,
disabled,
...restProps
} = props;
const onFormSubmitHandler: FormEventHandler<HTMLFormElement> = (event) => {
event.preventDefault();
onSubmit?.(item);
};
return (
<form ref={ref} onSubmit={onFormSubmitHandler} {...restProps}>
<h2>Удалить элемент ?</h2>
<button disabled={disabled} type="submit">Удалить</button>
</form>
);
};
export default DeleteIncomeItemForm;

View File

@ -0,0 +1,5 @@
.form {
display: flex;
flex-direction: column;
gap: 10px;
}

View File

@ -0,0 +1,115 @@
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import EditIncomeItemForm from './EditIncomeItemForm.ui';
import type { EditIncomeItemFormProps } from './EditIncomeItemForm.ui';
const editingItem = {
id: '51a44dc0-597b-49b9-85bb-e0bcd42db3f6',
title: 'Mock item',
amount: 12,
count: 12,
updatedAt: '2025-08-29T07:57:40.503Z',
incomeListId: '10cc093c-3875-4d33-a40a-df526266e262',
createdAt: '2025-08-29T07:57:40.503Z',
currency: 'RUB',
date: '2025-08-22T00:00:03.000Z',
description: 'Mock description',
};
const renderForm = (props?: Partial<EditIncomeItemFormProps>) => {
const onSubmit = jest.fn();
render(
<EditIncomeItemForm
data-testid="edit-income-item-form"
item={editingItem}
onSubmit={onSubmit}
{...props}
/>,
);
const titleInputElem = screen.getByLabelText('Название');
const descriptionInputElem = screen.getByLabelText('Описание');
const dateInputElem = screen.getByLabelText('Дата');
const amountInputElem = screen.getByLabelText('Сумма');
const submitButtonElem = screen.getByText('Сохранить');
return {
titleInputElem,
descriptionInputElem,
dateInputElem,
amountInputElem,
submitButtonElem,
onSubmit,
};
};
describe('Test EditIncomeItemForm', () => {
test('should render form', () => {
renderForm();
const form = screen.getByTestId('edit-income-item-form');
expect(form).toBeInTheDocument();
expect(form).toBeVisible();
});
test('should inputs have default value', () => {
const {
titleInputElem,
descriptionInputElem,
dateInputElem,
amountInputElem,
} = renderForm();
expect(titleInputElem).toHaveValue('Mock item');
expect(descriptionInputElem).toHaveValue('Mock description');
expect(dateInputElem).toHaveValue('2025-08-22');
expect(amountInputElem).toHaveValue(12);
});
test.each([
['enabled', {}, (el: HTMLElement) => {
expect(el).toBeEnabled();
}],
['disabled', { disabled: true }, (el: HTMLElement) => {
expect(el).toBeDisabled();
}],
])('should render fields %s', (_state, options, assertion) => {
const { onSubmit, ...formElems } = renderForm(options);
Object.values(formElems).forEach(assertion);
});
test('should call onSubmit with values', async () => {
const {
titleInputElem,
descriptionInputElem,
dateInputElem,
amountInputElem,
submitButtonElem,
onSubmit,
} = renderForm();
await userEvent.clear(titleInputElem);
await userEvent.type(titleInputElem, 'Название элемента');
await userEvent.clear(descriptionInputElem);
await userEvent.type(descriptionInputElem, 'Описание элемента');
await userEvent.clear(dateInputElem);
await userEvent.type(dateInputElem, '2025-08-29');
await userEvent.clear(amountInputElem);
await userEvent.type(amountInputElem, '12');
await userEvent.click(submitButtonElem);
expect(onSubmit).toHaveBeenCalledTimes(1);
expect(onSubmit).toHaveBeenCalledWith({
title: 'Название элемента',
description: 'Описание элемента',
date: '2025-08-29T00:00:00.000Z',
amount: 12,
currency: 'RUB',
});
});
});

View File

@ -0,0 +1,104 @@
import { Input } from '@/shared/ui/Input';
import classes from './EditIncomeItemForm.module.css';
import type { IncomeListItem } from '../../types';
import type {
FC,
FormEventHandler,
FormHTMLAttributes,
RefObject,
} from 'react';
export type EditIncomeItemFormValues = {
title: string;
description: string;
date: string;
amount: number;
currency: string;
};
type BaseFormProps = Omit<FormHTMLAttributes<HTMLFormElement>, 'onSubmit'>;
export type EditIncomeItemFormProps = BaseFormProps & {
disabled?: boolean;
onSubmit?: (data: EditIncomeItemFormValues) => Promise<void> | void;
ref?: RefObject<HTMLFormElement>;
item: IncomeListItem;
};
const EditIncomeItemForm: FC<EditIncomeItemFormProps> = (props) => {
const {
disabled,
onSubmit,
ref,
item,
...formProps
} = props;
// eslint-disable-next-line @typescript-eslint/no-misused-promises
const onFormSubmitHandler: FormEventHandler<HTMLFormElement> = async (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');
if (typeof title === 'string' && typeof date === 'string' && typeof amount === 'string' && typeof description === 'string') {
await onSubmit?.({
title,
date: new Date(date).toISOString(),
amount: Number(amount),
description,
currency: 'RUB',
});
}
};
return (
<form
ref={ref}
className={classes.form}
onSubmit={onFormSubmitHandler}
{...formProps}
>
<h2>Изменить доход</h2>
<Input
defaultValue={item.title}
disabled={disabled}
label="Название"
name="title"
/>
<Input
defaultValue={item.description ?? ''}
disabled={disabled}
label="Описание"
name="description"
/>
<Input
defaultValue={new Date(item.date).toISOString().split('T')[0]}
disabled={disabled}
label="Дата"
name="date"
type="date"
/>
<Input
defaultValue={item.amount}
disabled={disabled}
label="Сумма"
name="amount"
step="0.01"
type="number"
/>
<button disabled={disabled} type="submit">Сохранить</button>
</form>
);
};
export default EditIncomeItemForm;

View File

@ -0,0 +1,13 @@
import { api } from '@/shared/api/client';
import type { paths } from '@/shared/api/schema';
export type AddIncomeListPath = paths['/incomes/list']['post']['parameters']['path'];
export type AddIncomeListBody = paths['/incomes/list']['post']['requestBody']['content']['application/json'];
export type AddIncomeListSuccessResponse = paths['/incomes/list']['post']['responses']['200']['content']['application/json'];
export async function addIncomeList(data: AddIncomeListBody) {
const { data: result } = await api.post<AddIncomeListSuccessResponse>('/incomes/list', data);
return result;
}

View File

@ -0,0 +1,13 @@
import { api } from '@/shared/api/client';
import type { paths } from '@/shared/api/schema';
export type DeleteIncomeListPath = paths['/incomes/list/{id}']['delete']['parameters']['path'];
export type DeleteIncomeListSuccessResponse = paths['/incomes/list/{id}']['delete']['responses']['200']['content']['application/json'];
export async function deleteIncomeList(data: DeleteIncomeListPath) {
const { id } = data;
const { data: result } = await api.delete<DeleteIncomeListSuccessResponse>(`/incomes/list/${id}`);
return result;
}

View File

@ -0,0 +1,17 @@
import { api } from '@/shared/api/client';
import type { paths } from '@/shared/api/schema';
export type EditIncomeListPath = paths['/incomes/list/{id}']['patch']['parameters']['path'];
export type EditIncomeListBody = paths['/incomes/list/{id}']['patch']['requestBody']['content']['application/json'];
export type EditIncomeListSuccessResponse = paths['/incomes/list/{id}']['patch']['responses']['200']['content']['application/json'];
export async function editIncomeList({ data, path }: {
path: EditIncomeListPath;
data: EditIncomeListBody;
}) {
const { id } = path;
const { data: result } = await api.patch<EditIncomeListSuccessResponse>(`/incomes/list/${id}`, data);
return result;
}

View File

@ -0,0 +1,12 @@
import { api } from '@/shared/api/client';
import type { paths } from '@/shared/api/schema';
type IncomeListPath = paths['/incomes/list/{id}']['get']['parameters']['path'];
type IncomeListResponse = paths['/incomes/list/{id}']['get']['responses']['200']['content']['application/json'];
export async function getIncomeList(path: IncomeListPath) {
const { data: result } = await api.get<IncomeListResponse>(`/incomes/list/${path.id}`);
return result;
}

View File

@ -0,0 +1,11 @@
import { api } from '@/shared/api/client';
import type { paths } from '@/shared/api/schema';
type IncomesListResponse = paths['/incomes/list']['get']['responses']['200']['content']['application/json'];
export async function getIncomeLists() {
const { data: result } = await api.get<IncomesListResponse>('/incomes/list');
return result;
}

View File

@ -0,0 +1,17 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { QueryKeys } from '@/shared/api/queryKeys';
import { addIncomeList } from '../api/addIncomeList';
export const useAddIncomesList = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: addIncomeList,
mutationKey: [QueryKeys.ExpenseList],
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: [QueryKeys.IncomesList] });
},
});
};

View File

@ -0,0 +1,17 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { QueryKeys } from '@/shared/api/queryKeys';
import { deleteIncomeList } from '../api/deleteIncomeList';
export const useDeleteIncomesList = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: deleteIncomeList,
mutationKey: [QueryKeys.IncomesList],
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: [QueryKeys.IncomesList] });
},
});
};

View File

@ -0,0 +1,17 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { QueryKeys } from '@/shared/api/queryKeys';
import { editIncomeList } from '../api/editIncomeList';
export const useEditIncomesList = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: editIncomeList,
mutationKey: [QueryKeys.IncomesList],
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: [QueryKeys.IncomesList] });
},
});
};

View File

@ -0,0 +1,12 @@
import { useQuery } from '@tanstack/react-query';
import { QueryKeys } from '@/shared/api/queryKeys';
import { getIncomeList } from '../api/getIncomeList';
import type { IncomeList } from '../types';
export const useIncomeList = (id: IncomeList['id']) => useQuery({
queryFn: async () => getIncomeList({ id }),
queryKey: [QueryKeys.IncomesList, id],
});

View File

@ -0,0 +1,10 @@
import { useQuery } from '@tanstack/react-query';
import { QueryKeys } from '@/shared/api/queryKeys';
import { getIncomeLists } from '../api/getIncomeLists';
export const useIncomesList = () => useQuery({
queryFn: getIncomeLists,
queryKey: [QueryKeys.IncomesList],
});

View File

@ -0,0 +1,19 @@
export type { IncomeList } from './types';
export { useDeleteIncomesList } from './hooks/useDeleteIncomesList';
export { useAddIncomesList } from './hooks/useAddIncomesList';
export { useIncomesList } from './hooks/useIncomesList';
export { useEditIncomesList } from './hooks/useEditIncomesList';
export { useIncomeList } from './hooks/useIncomeList';
export type { AddIncomeListFormValues } from './ui/AddIncomeListForm/ui/AddIncomeListForm.ui';
export { default as AddIncomeListForm } from './ui/AddIncomeListForm/ui/AddIncomeListForm.ui';
export { default as DeleteIncomeListForm } from './ui/DeleteIncomeListForm/ui/DeleteIncomeListForm.ui';
export type { EditIncomeListFormValues } from './ui/EditIncomeListForm/ui/EditIncomeListForm.ui';
export { default as EditIncomeListForm } from './ui/EditIncomeListForm/ui/EditIncomeListForm.ui';
export type { AddIncomeListBody, AddIncomeListPath, AddIncomeListSuccessResponse } from './api/addIncomeList';
export type { DeleteIncomeListPath, DeleteIncomeListSuccessResponse } from './api/deleteIncomeList';
export type { EditIncomeListBody, EditIncomeListPath, EditIncomeListSuccessResponse } from './api/editIncomeList';

View File

@ -0,0 +1,3 @@
import type { paths } from '@/shared/api/schema';
export type IncomeList = paths['/incomes/list/{id}']['get']['responses']['200']['content']['application/json'];

View File

@ -0,0 +1,5 @@
.form {
display: flex;
flex-direction: column;
gap: 10px;
}

View File

@ -0,0 +1,58 @@
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import AddIncomeListForm from './AddIncomeListForm.ui';
import type { AddIncomeListFormProps } from './AddIncomeListForm.ui';
const renderForm = (props?: Partial<AddIncomeListFormProps>) => {
const onSubmit = jest.fn();
render(<AddIncomeListForm data-testid="add-income-list-form" onSubmit={onSubmit} {...props} />);
const titleInputElem = screen.getByLabelText('Название');
const submitButtonElem = screen.getByText('Добавить');
return {
titleInputElem,
submitButtonElem,
onSubmit,
};
};
describe('Test AddIncomeListForm', () => {
test('should render form', () => {
renderForm();
const form = screen.getByTestId('add-income-list-form');
expect(form).toBeInTheDocument();
expect(form).toBeVisible();
});
test.each([
['enabled', {}, (el: HTMLElement) => {
expect(el).toBeEnabled();
}],
['disabled', { disabled: true }, (el: HTMLElement) => {
expect(el).toBeDisabled();
}],
])('should render fields %s', (_state, options, assertion) => {
const { onSubmit, ...formElems } = renderForm(options);
Object.values(formElems).forEach(assertion);
});
test('should call onSubmit with values', async () => {
const {
titleInputElem,
submitButtonElem,
onSubmit,
} = renderForm();
await userEvent.type(titleInputElem, 'Название списка');
await userEvent.click(submitButtonElem);
expect(onSubmit).toHaveBeenCalledTimes(1);
expect(onSubmit).toHaveBeenCalledWith({
title: 'Название списка',
});
});
});

View File

@ -0,0 +1,62 @@
import { Input } from '@/shared/ui/Input';
import classes from './AddIncomeListForm.module.css';
import type {
FormHTMLAttributes,
RefObject,
FormEventHandler,
FC,
} from 'react';
export type AddIncomeListFormValues = {
title: string;
};
type BaseFormProps = Omit<FormHTMLAttributes<HTMLFormElement>, 'onSubmit'>;
export type AddIncomeListFormProps = BaseFormProps & {
disabled?: boolean;
onSubmit: (data: AddIncomeListFormValues) => Promise<void> | void;
ref?: RefObject<HTMLFormElement>;
};
const AddIncomeListForm: FC<AddIncomeListFormProps> = (props) => {
const {
ref,
disabled,
onSubmit,
...restProps
} = props;
const onFormSubmitHandler: FormEventHandler<HTMLFormElement> = (event) => {
event.preventDefault();
const formData = new FormData(event.currentTarget);
const title = formData.get('title');
if (typeof title === 'string') {
onSubmit({
title,
});
}
};
return (
<form
ref={ref}
className={classes.form}
onSubmit={onFormSubmitHandler}
{...restProps}
>
<h2>Добавить список доходов</h2>
<Input disabled={disabled} label="Название" name="title" />
<button disabled={disabled} type="submit">Добавить</button>
</form>
);
};
export default AddIncomeListForm;

View File

@ -0,0 +1,55 @@
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import DeleteIncomeListForm from './DeleteIncomeListForm.ui';
import type { DeleteIncomeListFormProps } from './DeleteIncomeListForm.ui';
const deletingList = {
id: '10cc093c-3875-4d33-a40a-df526266e262',
title: 'Mock list',
items: [],
updatedAt: '2025-08-29T07:57:40.503Z',
createdAt: '2025-08-29T07:57:40.503Z',
};
const renderForm = (props?: Partial<DeleteIncomeListFormProps>) => {
const onSubmit = jest.fn();
render(
<DeleteIncomeListForm
data-testid="delete-income-list-form"
list={deletingList}
onSubmit={onSubmit}
{...props}
/>,
);
const submitButtonElem = screen.getByText('Удалить');
return {
submitButtonElem,
onSubmit,
};
};
describe('Test DeleteIncomeListForm', () => {
test('should render form', () => {
renderForm();
const form = screen.getByTestId('delete-income-list-form');
expect(form).toBeInTheDocument();
expect(form).toBeVisible();
});
test('should call onSubmit with values', async () => {
const {
submitButtonElem,
onSubmit,
} = renderForm();
await userEvent.click(submitButtonElem);
expect(onSubmit).toHaveBeenCalledTimes(1);
expect(onSubmit).toHaveBeenCalledWith(deletingList);
});
});

View File

@ -0,0 +1,71 @@
import type { IncomeList } from '../../../types';
import type {
FC,
FormEventHandler,
FormHTMLAttributes,
RefObject,
} from 'react';
type BaseFormProps = Omit<FormHTMLAttributes<HTMLFormElement>, 'onSubmit'>;
export type DeleteIncomeListFormProps = BaseFormProps & {
disabled?: boolean;
onSubmit?: (list: IncomeList) => Promise<void> | void;
ref?: RefObject<HTMLFormElement>;
list: IncomeList;
};
const DeleteIncomeListForm: FC<DeleteIncomeListFormProps> = (props) => {
const {
ref,
disabled,
onSubmit,
list,
...restProps
} = props;
const onFormSubmitHandler: FormEventHandler<HTMLFormElement> = (event) => {
event.preventDefault();
onSubmit?.(list);
};
return (
<form ref={ref} onSubmit={onFormSubmitHandler} {...restProps}>
<h2>Удалить список доходов</h2>
<p>
Вы действительно хотите удалить список доходов
{' '}
<b>
{`"${list.title}"`}
</b>
{' '}
?
</p>
{
list.items && list.items.length > 0
? (
<p>
В нем содержится
{' '}
<b>
{list.items.length}
</b>
{' '}
доходов
</p>
)
: null
}
<button disabled={disabled} type="submit">Удалить</button>
</form>
);
};
export default DeleteIncomeListForm;

View File

@ -0,0 +1,5 @@
.form {
display: flex;
flex-direction: column;
gap: 10px;
}

View File

@ -0,0 +1,82 @@
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import EditIncomeListForm from './EditIncomeListForm.ui';
import type { EditExpensesListFormProps } from './EditIncomeListForm.ui';
const editingList = {
id: '10cc093c-3875-4d33-a40a-df526266e262',
title: 'Mock list',
items: [],
updatedAt: '2025-08-29T07:57:40.503Z',
createdAt: '2025-08-29T07:57:40.503Z',
};
const renderForm = (props?: Partial<EditExpensesListFormProps>) => {
const onSubmit = jest.fn();
render(
<EditIncomeListForm
data-testid="edit-income-list-form"
list={editingList}
onSubmit={onSubmit}
{...props}
/>,
);
const titleInputElem = screen.getByLabelText('Название');
const submitButtonElem = screen.getByText('Сохранить');
return {
titleInputElem,
submitButtonElem,
onSubmit,
};
};
describe('Test EditIncomeListForm', () => {
test('should render form', () => {
renderForm();
const form = screen.getByTestId('edit-income-list-form');
expect(form).toBeInTheDocument();
expect(form).toBeVisible();
});
test('should inputs have default value', () => {
const {
titleInputElem,
} = renderForm();
expect(titleInputElem).toHaveValue('Mock list');
});
test.each([
['enabled', {}, (el: HTMLElement) => {
expect(el).toBeEnabled();
}],
['disabled', { disabled: true }, (el: HTMLElement) => {
expect(el).toBeDisabled();
}],
])('should render fields %s', (_state, options, assertion) => {
const { onSubmit, ...formElems } = renderForm(options);
Object.values(formElems).forEach(assertion);
});
test('should call onSubmit with values', async () => {
const {
titleInputElem,
submitButtonElem,
onSubmit,
} = renderForm();
await userEvent.clear(titleInputElem);
await userEvent.type(titleInputElem, 'Название списка');
await userEvent.click(submitButtonElem);
expect(onSubmit).toHaveBeenCalledTimes(1);
expect(onSubmit).toHaveBeenCalledWith({
title: 'Название списка',
});
});
});

View File

@ -0,0 +1,68 @@
import { Input } from '@/shared/ui/Input';
import classes from './EditIncomeListForm.module.css';
import type { IncomeList } from '../../../types';
import type {
FC,
FormEventHandler,
FormHTMLAttributes,
RefObject,
} from 'react';
export type EditIncomeListFormValues = {
title: string;
};
type BaseFormProps = Omit<FormHTMLAttributes<HTMLFormElement>, 'onSubmit'>;
export type EditExpensesListFormProps = BaseFormProps & {
disabled?: boolean;
onSubmit: (data: EditIncomeListFormValues) => Promise<void> | void;
ref?: RefObject<HTMLFormElement>;
list: IncomeList;
};
const EditExpensesListForm: FC<EditExpensesListFormProps> = (props) => {
const {
ref,
disabled,
onSubmit,
list,
...restProps
} = props;
const onFormSubmitHandler: FormEventHandler<HTMLFormElement> = (event) => {
event.preventDefault();
const formData = new FormData(event.currentTarget);
const title = formData.get('title');
if (typeof title === 'string') {
onSubmit({
title,
});
}
};
return (
<form
ref={ref}
className={classes.form}
onSubmit={onFormSubmitHandler}
{...restProps}
>
<h2>Редактировать список доходов</h2>
<Input
defaultValue={list.title}
disabled={disabled}
label="Название"
name="title"
/>
<button disabled={disabled} type="submit">Сохранить</button>
</form>
);
};
export default EditExpensesListForm;

View File

@ -0,0 +1,41 @@
import { useState } from 'react';
import { useAddIncomeItem } from '@/entity/incomes/item';
import type { AddIncomeItemFormValues } from '@/entity/incomes/item';
import type { IncomeList } from '@/entity/incomes/list';
import type { ButtonHTMLAttributes, MouseEventHandler } from 'react';
type UseAddIncomeItemButtonArgs = {
listId: IncomeList['id'];
onClick?: ButtonHTMLAttributes<HTMLButtonElement>['onClick'];
};
export const useAddIncomeItemButton = (args: UseAddIncomeItemButtonArgs) => {
const { listId, onClick } = args;
const [isOpen, setIsOpen] = useState(false);
const { mutateAsync: addIncomeItemMutation, isPending } = useAddIncomeItem({ id: listId });
const onCloseModalHandler = () => {
setIsOpen(false);
};
const onClickHandler: MouseEventHandler<HTMLButtonElement> = (event) => {
setIsOpen(true);
onClick?.(event);
};
const onFormSubmitHandler = async (data: AddIncomeItemFormValues) => {
// todo добавить обработку ошибок (код + тест)
await addIncomeItemMutation(data);
setIsOpen(false);
};
return {
isOpen,
onFormSubmitHandler,
onCloseModalHandler,
isPending,
onClickHandler,
};
};

View File

@ -0,0 +1 @@
export { default as AddIncomeItemButton } from './ui/AddIncomeItemButton.ui';

View File

@ -0,0 +1,122 @@
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { http, HttpResponse } from 'msw';
import { server } from '@mocks/jest/server';
import { createReactQueryWrapper } from '@mocks/jest/wrappers';
import AddIncomeItemButton from './AddIncomeItemButton.ui';
import type { AddIncomeItemBody, AddIncomeItemPath, AddIncomeItemSuccessResponse } from '@/entity/incomes/item';
const renderButton = () => {
render(
<AddIncomeItemButton data-testid="test-button" listId="123" />,
{ wrapper: createReactQueryWrapper() },
);
const buttonElem = screen.getByTestId('test-button');
return {
buttonElem,
};
};
const openModal = async () => {
const { buttonElem } = renderButton();
await userEvent.click(buttonElem);
const modalElem = screen.getByRole('dialog');
return {
modalElem,
};
};
const fillForm = async (data: { title: string; description: string; date: string; amount: string }) => {
await userEvent.type(screen.getByLabelText('Название'), data.title);
await userEvent.type(screen.getByLabelText('Описание'), data.description);
await userEvent.type(screen.getByLabelText('Дата'), data.date);
await userEvent.type(screen.getByLabelText('Сумма'), data.amount);
};
const submitHandler = (requestSpy: jest.Mock) => http.post<
AddIncomeItemPath,
AddIncomeItemBody,
AddIncomeItemSuccessResponse
>(
'/incomes/list/123/items',
async ({ request }) => {
const body = await request.json();
requestSpy(body);
return HttpResponse.json({
id: '51a44dc0-597b-49b9-85bb-e0bcd42db3f6',
title: 'Mock item',
amount: 12,
updatedAt: '2025-08-29T07:57:40.503Z',
incomeListId: '10cc093c-3875-4d33-a40a-df526266e262',
createdAt: '2025-08-29T07:57:40.503Z',
currency: 'RUB',
date: '2025-08-22T00:00:03.000Z',
description: 'Mock description',
});
},
);
describe('Test AddIncomeItemButton', () => {
test('should correct render button', () => {
const { buttonElem } = renderButton();
expect(buttonElem).toBeInTheDocument();
expect(buttonElem).toBeVisible();
expect(buttonElem).toBeEnabled();
expect(buttonElem).toHaveTextContent('Добавить элемент');
const modal = screen.queryByRole('dialog');
expect(modal).not.toBeInTheDocument();
});
test('should open modal after click', async () => {
const { modalElem } = await openModal();
expect(modalElem).toBeInTheDocument();
expect(modalElem).toBeVisible();
});
describe('should close modal after submit and send request', () => {
let modalElem: HTMLElement;
let submitButtonElem: HTMLElement;
const requestSpy = jest.fn();
beforeEach(async () => {
server.use(submitHandler(requestSpy));
modalElem = (await openModal()).modalElem;
submitButtonElem = screen.getByText('Добавить');
await fillForm({
title: 'Название элемента',
description: 'Описание элемента',
date: '2025-08-29',
amount: '12',
});
});
test('should send correct request', async () => {
await userEvent.click(submitButtonElem);
expect(requestSpy).toHaveBeenCalledTimes(1);
expect(requestSpy).toHaveBeenCalledWith({
title: 'Название элемента',
description: 'Описание элемента',
date: '2025-08-29T00:00:00.000Z',
amount: 12,
currency: 'RUB',
});
});
test('should close modal after submit', async () => {
expect(submitButtonElem).toBeEnabled();
await userEvent.click(submitButtonElem);
expect(modalElem).not.toBeInTheDocument();
});
});
});

View File

@ -0,0 +1,51 @@
import { AddIncomeItemForm } from '@/entity/incomes/item';
import { Modal } from '@/shared/ui/Modal';
import { useAddIncomeItemButton } from '../hooks/useAddIncomeItemButton';
import type { IncomeList } from '@/entity/incomes/list';
import type { ButtonHTMLAttributes, FC, RefObject } from 'react';
type BaseButtonProps = ButtonHTMLAttributes<HTMLButtonElement>;
type AddIncomeItemButtonProps = BaseButtonProps & {
listId: IncomeList['id'];
ref?: RefObject<HTMLButtonElement>;
};
const AddIncomeItemButton: FC<AddIncomeItemButtonProps> = (props) => {
const {
listId,
ref,
onClick,
...buttonProps
} = props;
const {
isOpen,
isPending,
onClickHandler,
onCloseModalHandler,
onFormSubmitHandler,
} = useAddIncomeItemButton({ listId, onClick });
return (
<>
<button
ref={ref}
type="button"
onClick={onClickHandler}
{...buttonProps}
>
Добавить элемент
</button>
<Modal open={isOpen} onClose={onCloseModalHandler}>
<AddIncomeItemForm disabled={isPending} onSubmit={onFormSubmitHandler} />
</Modal>
</>
);
};
export default AddIncomeItemButton;

View File

@ -0,0 +1,41 @@
import { useState } from 'react';
import { type IncomeListItem, useDeleteIncomeItem } from '@/entity/incomes/item';
import type { MouseEventHandler } from 'react';
type UseDeleteIncomeItemButtonArgs = {
item: IncomeListItem;
onClick?: MouseEventHandler<HTMLButtonElement>;
};
export const useDeleteIncomeItemButton = (args: UseDeleteIncomeItemButtonArgs) => {
const { item, onClick } = args;
const [isOpen, setIsOpen] = useState(false);
const onCloseModalHandler = () => {
setIsOpen(false);
};
const onClickHandler: MouseEventHandler<HTMLButtonElement> = (event) => {
setIsOpen(true);
onClick?.(event);
};
const { mutateAsync: deleteIncomeItemMutation, isPending } = useDeleteIncomeItem({
listId: item.incomeListId,
id: item.id,
});
const onFormSubmitHandler = async () => {
await deleteIncomeItemMutation();
setIsOpen(false);
};
return {
isOpen,
onCloseModalHandler,
onClickHandler,
isPending,
onFormSubmitHandler,
};
};

View File

@ -0,0 +1 @@
export { default as DeleteIncomeItemButton } from './ui/DeleteIncomeItemButton.ui';

View File

@ -0,0 +1,78 @@
import { render, screen, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { http, HttpResponse } from 'msw';
import { server } from '@mocks/jest/server';
import { createReactQueryWrapper } from '@mocks/jest/wrappers';
import DeleteIncomeItemButton from './DeleteIncomeItemButton.ui';
import type { DeleteIncomeItemPath, IncomeListItem } from '@/entity/incomes/item';
const deletedItem: IncomeListItem = {
id: '51a44dc0-597b-49b9-85bb-e0bcd42db3f6',
title: 'Mock item',
amount: 12,
updatedAt: '2025-08-29T07:57:40.503Z',
incomeListId: '10cc093c-3875-4d33-a40a-df526266e262',
createdAt: '2025-08-29T07:57:40.503Z',
currency: 'RUB',
date: '2025-08-22T00:00:03.000Z',
description: 'Mock description',
};
const renderButton = () => {
render(
<DeleteIncomeItemButton data-testid="delete-income-item-button" item={deletedItem} />,
{ wrapper: createReactQueryWrapper() },
);
const buttonElem = screen.getByTestId('delete-income-item-button');
return {
buttonElem,
};
};
const openModal = async () => {
const { buttonElem } = renderButton();
await userEvent.click(buttonElem);
const modalElem = screen.getByRole('dialog');
return {
modalElem,
};
};
const submitHandler = http.delete<DeleteIncomeItemPath>(`/incomes/list/${deletedItem.incomeListId}/items/${deletedItem.id}`, () => HttpResponse.json(deletedItem));
describe('Test DeleteIncomeItemButton', () => {
test('should correct render button', () => {
const { buttonElem } = renderButton();
expect(buttonElem).toBeInTheDocument();
expect(buttonElem).toBeVisible();
expect(buttonElem).toBeEnabled();
expect(buttonElem).toHaveTextContent('Удалить');
const modal = screen.queryByRole('dialog');
expect(modal).not.toBeInTheDocument();
});
test('should open modal after click', async () => {
const { modalElem } = await openModal();
expect(modalElem).toBeInTheDocument();
expect(modalElem).toBeVisible();
});
test('should close modal after submit', async () => {
server.use(submitHandler);
const { modalElem } = await openModal();
const submitButtonElem = within(modalElem).getByText('Удалить');
expect(submitButtonElem).toBeEnabled();
await userEvent.click(submitButtonElem);
expect(modalElem).not.toBeInTheDocument();
});
});

View File

@ -0,0 +1,48 @@
import { DeleteIncomeItemForm } from '@/entity/incomes/item';
import { Modal } from '@/shared/ui/Modal';
import { useDeleteIncomeItemButton } from '../hooks/useDeleteIncomeItemButton';
import type { IncomeListItem } from '@/entity/incomes/item';
import type { ButtonHTMLAttributes, FC, RefObject } from 'react';
type BaseButtonProps = ButtonHTMLAttributes<HTMLButtonElement>;
type DeleteIncomeItemButtonProps = BaseButtonProps & {
item: IncomeListItem;
ref?: RefObject<HTMLButtonElement>;
};
const DeleteIncomeItemButton: FC<DeleteIncomeItemButtonProps> = (props) => {
const {
item,
ref,
onClick,
...restProps
} = props;
const {
isOpen,
onCloseModalHandler,
onClickHandler,
isPending,
onFormSubmitHandler,
} = useDeleteIncomeItemButton({ item, onClick });
return (
<>
<button
ref={ref}
type="button"
onClick={onClickHandler}
{...restProps}
>
Удалить
</button>
<Modal open={isOpen} onClose={onCloseModalHandler}>
<DeleteIncomeItemForm disabled={isPending} item={item} onSubmit={onFormSubmitHandler} />
</Modal>
</>
);
};
export default DeleteIncomeItemButton;

View File

@ -0,0 +1,42 @@
import { useState } from 'react';
import { type IncomeListItem, useEditIncomeItem, type EditIncomeItemFormValues } from '@/entity/incomes/item';
import type { MouseEventHandler } from 'react';
type UseAddIncomeItemButtonArgs = {
item: IncomeListItem;
onClick?: MouseEventHandler<HTMLButtonElement>;
};
export const useIncomeItemButton = (args: UseAddIncomeItemButtonArgs) => {
const { item, onClick } = args;
const [isOpen, setIsOpen] = useState(false);
const { mutateAsync: editIncomeItemMutation, isPending } = useEditIncomeItem({
id: item.id,
listId: item.incomeListId,
});
const onCloseModalHandler = () => {
setIsOpen(false);
};
const onClickHandler: MouseEventHandler<HTMLButtonElement> = (event) => {
setIsOpen(true);
onClick?.(event);
};
const onFormSubmitHandler = async (data: EditIncomeItemFormValues) => {
// todo добавить обработку ошибок (код + тест)
await editIncomeItemMutation(data);
setIsOpen(false);
};
return {
isOpen,
onFormSubmitHandler,
onCloseModalHandler,
isPending,
onClickHandler,
};
};

View File

@ -0,0 +1 @@
export { default as EditIncomeItemButton } from './ui/EditIncomeItemButton.ui';

View File

@ -0,0 +1,140 @@
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { http, HttpResponse } from 'msw';
import { server } from '@mocks/jest/server';
import { createReactQueryWrapper } from '@mocks/jest/wrappers';
import EditIncomeItemButton from './EditIncomeItemButton.ui';
import type {
EditIncomeItemBody,
EditIncomeItemPath,
EditIncomeItemSuccessResponse,
IncomeListItem,
} from '@/entity/incomes/item';
const editingItem: IncomeListItem = {
id: '51a44dc0-597b-49b9-85bb-e0bcd42db3f6',
title: 'Mock item',
amount: 12,
updatedAt: '2025-08-29T07:57:40.503Z',
incomeListId: '10cc093c-3875-4d33-a40a-df526266e262',
createdAt: '2025-08-29T07:57:40.503Z',
currency: 'RUB',
date: '2025-08-22T00:00:03.000Z',
description: 'Mock description',
};
const renderButton = () => {
render(
<EditIncomeItemButton data-testid="edit-income-item-button" item={editingItem} />,
{ wrapper: createReactQueryWrapper() },
);
const buttonElem = screen.getByTestId('edit-income-item-button');
return {
buttonElem,
};
};
const openModal = async () => {
const { buttonElem } = renderButton();
await userEvent.click(buttonElem);
const modalElem = screen.getByRole('dialog');
return {
modalElem,
};
};
const submitHandler = (requestSpy: jest.Mock) => http.patch<
EditIncomeItemPath,
EditIncomeItemBody,
EditIncomeItemSuccessResponse
>(
`/incomes/list/${editingItem.incomeListId}/items/${editingItem.id}`,
async ({ request }) => {
const body = await request.json();
requestSpy(body);
return HttpResponse.json(editingItem);
},
);
const fillForm = async (data: { title: string; description: string; date: string; amount: string }) => {
const titleInputElem = screen.getByLabelText('Название');
await userEvent.clear(titleInputElem);
await userEvent.type(titleInputElem, data.title);
const descriptionInputElem = screen.getByLabelText('Описание');
await userEvent.clear(descriptionInputElem);
await userEvent.type(descriptionInputElem, data.description);
const dateInputElem = screen.getByLabelText('Дата');
await userEvent.clear(dateInputElem);
await userEvent.type(dateInputElem, data.date);
const amountInputElem = screen.getByLabelText('Сумма');
await userEvent.clear(amountInputElem);
await userEvent.type(amountInputElem, data.amount);
};
describe('TestEditIncomeItemButton', () => {
test('should correct render button', () => {
const { buttonElem } = renderButton();
expect(buttonElem).toBeInTheDocument();
expect(buttonElem).toBeVisible();
expect(buttonElem).toBeEnabled();
expect(buttonElem).toHaveTextContent('Редактировать');
const modal = screen.queryByRole('dialog');
expect(modal).not.toBeInTheDocument();
});
test('should open modal after click', async () => {
const { modalElem } = await openModal();
expect(modalElem).toBeInTheDocument();
expect(modalElem).toBeVisible();
});
describe('should close modal after submit and send request', () => {
let modalElem: HTMLElement;
let submitButtonElem: HTMLElement;
const requestSpy = jest.fn();
beforeEach(async () => {
server.use(submitHandler(requestSpy));
modalElem = (await openModal()).modalElem;
submitButtonElem = screen.getByText('Сохранить');
await fillForm({
title: 'Название элемента',
description: 'Описание элемента',
date: '2025-08-29',
amount: '12',
});
});
test('should send correct request', async () => {
await userEvent.click(submitButtonElem);
expect(requestSpy).toHaveBeenCalledTimes(1);
expect(requestSpy).toHaveBeenCalledWith({
title: 'Название элемента',
description: 'Описание элемента',
date: '2025-08-29T00:00:00.000Z',
amount: 12,
currency: 'RUB',
});
});
test('should close modal after submit', async () => {
expect(submitButtonElem).toBeEnabled();
await userEvent.click(submitButtonElem);
expect(modalElem).not.toBeInTheDocument();
});
});
});

View File

@ -0,0 +1,51 @@
import { EditIncomeItemForm } from '@/entity/incomes/item';
import { Modal } from '@/shared/ui/Modal';
import { useIncomeItemButton } from '../hooks/useIncomeItemButton';
import type { IncomeListItem } from '@/entity/incomes/item';
import type { ButtonHTMLAttributes, FC, RefObject } from 'react';
type BaseButtonProps = ButtonHTMLAttributes<HTMLButtonElement>;
type EditIncomeItemButtonProps = BaseButtonProps & {
item: IncomeListItem;
ref?: RefObject<HTMLButtonElement>;
};
const EditIncomeItemButton: FC<EditIncomeItemButtonProps> = (props) => {
const {
ref,
item,
onClick,
...restProps
} = props;
const {
isOpen,
onCloseModalHandler,
onClickHandler,
isPending,
onFormSubmitHandler,
} = useIncomeItemButton({ item, onClick });
return (
<>
<button
ref={ref}
disabled={isPending}
type="button"
onClick={onClickHandler}
{...restProps}
>
Редактировать
</button>
<Modal open={isOpen} onClose={onCloseModalHandler}>
<EditIncomeItemForm disabled={isPending} item={item} onSubmit={onFormSubmitHandler} />
</Modal>
</>
);
};
export default EditIncomeItemButton;

View File

@ -0,0 +1,38 @@
import { type MouseEventHandler, useState } from 'react';
import { useAddIncomesList } from '@/entity/incomes/list';
import type { AddExpenseListFormValues } from '@/entity/expenses/list';
type UseAddIncomeListButtonArgs = {
onClick?: MouseEventHandler<HTMLButtonElement>;
};
export const useAddIncomeListButton = (args: UseAddIncomeListButtonArgs) => {
const { onClick } = args;
const [isOpen, setIsOpen] = useState(false);
const { mutateAsync: addIncomeListMutation, isPending } = useAddIncomesList();
const onCloseModalHandler = () => {
setIsOpen(false);
};
const onClickHandler: MouseEventHandler<HTMLButtonElement> = (event) => {
onClick?.(event);
setIsOpen(true);
};
const onFormSubmitHandler = async (data: AddExpenseListFormValues) => {
await addIncomeListMutation(data);
setIsOpen(false);
};
return {
isOpen,
isPending,
onCloseModalHandler,
onClickHandler,
onFormSubmitHandler,
};
};

View File

@ -0,0 +1 @@
export { default as AddIncomeListButton } from './ui/AddIncomeListButton.ui';

View File

@ -0,0 +1,104 @@
import { render, screen, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { http, HttpResponse } from 'msw';
import { server } from '@mocks/jest/server';
import { createReactQueryWrapper } from '@mocks/jest/wrappers';
import AddIncomeListButton from './AddIncomeListButton.ui';
import type { AddExpenseListBody, AddExpenseListSuccessResponse } from '@/entity/expenses/list';
const renderButton = () => {
render(
<AddIncomeListButton data-testid="test-button" />,
{ wrapper: createReactQueryWrapper() },
);
const buttonElem = screen.getByTestId('test-button');
return {
buttonElem,
};
};
const openModal = async () => {
const { buttonElem } = renderButton();
await userEvent.click(buttonElem);
const modalElem = screen.getByRole('dialog');
return {
modalElem,
};
};
const fillForm = async (data: { title: string }) => {
await userEvent.type(screen.getByLabelText('Название'), data.title);
};
const submitHandler = (requestSpy: jest.Mock) => http.post<never, AddExpenseListBody, AddExpenseListSuccessResponse>(
'/incomes/list',
async ({ request }) => {
const body = await request.json();
requestSpy(body);
return HttpResponse.json({
id: '10cc093c-3875-4d33-a40a-df526266e262',
title: 'Mock list',
items: [],
updatedAt: '2025-08-29T07:57:40.503Z',
createdAt: '2025-08-29T07:57:40.503Z',
});
},
);
describe('Test AddIncomeListButton', () => {
test('should correct render button', () => {
const { buttonElem } = renderButton();
expect(buttonElem).toBeInTheDocument();
expect(buttonElem).toBeVisible();
expect(buttonElem).toBeEnabled();
expect(buttonElem).toHaveTextContent('Добавить');
const modal = screen.queryByRole('dialog');
expect(modal).not.toBeInTheDocument();
});
test('should open modal after click', async () => {
const { modalElem } = await openModal();
expect(modalElem).toBeInTheDocument();
expect(modalElem).toBeVisible();
});
describe('should close modal after submit and send request', () => {
let modalElem: HTMLElement;
let submitButtonElem: HTMLElement;
const requestSpy = jest.fn();
beforeEach(async () => {
server.use(submitHandler(requestSpy));
modalElem = (await openModal()).modalElem;
submitButtonElem = within(modalElem).getByText('Добавить');
await fillForm({
title: 'Название списка',
});
});
test('should send correct request', async () => {
await userEvent.click(submitButtonElem);
expect(requestSpy).toHaveBeenCalledTimes(1);
expect(requestSpy).toHaveBeenCalledWith({
title: 'Название списка',
});
});
test('should close modal after submit', async () => {
expect(submitButtonElem).toBeEnabled();
await userEvent.click(submitButtonElem);
expect(modalElem).not.toBeInTheDocument();
});
});
});

View File

@ -0,0 +1,36 @@
import { AddIncomeListForm } from '@/entity/incomes/list';
import { Modal } from '@/shared/ui/Modal';
import { useAddIncomeListButton } from '../hooks/useAddIncomeListButton';
import type { ButtonHTMLAttributes, FC, Ref } from 'react';
type AddIncomeListButtonProps = ButtonHTMLAttributes<HTMLButtonElement> & {
ref?: Ref<HTMLButtonElement>;
};
const AddIncomeListButton: FC<AddIncomeListButtonProps> = (props) => {
const { onClick, ...restProps } = props;
const {
isOpen,
isPending,
onCloseModalHandler,
onClickHandler,
onFormSubmitHandler,
} = useAddIncomeListButton({ onClick });
return (
<>
<button type="button" onClick={onClickHandler} {...restProps}>
Добавить
</button>
<Modal open={isOpen} onClose={onCloseModalHandler}>
<AddIncomeListForm disabled={isPending} onSubmit={onFormSubmitHandler} />
</Modal>
</>
);
};
export default AddIncomeListButton;

View File

@ -0,0 +1,35 @@
import { useState } from 'react';
import { type IncomeList, useDeleteIncomesList } from '@/entity/incomes/list';
type UseDeleteIncomeListButtonArgs = {
id: IncomeList['id'];
};
export const useIncomeListButton = (args: UseDeleteIncomeListButtonArgs) => {
const { id } = args;
const [isOpen, setIsOpen] = useState(false);
const { mutateAsync: deleteIncomeListAsync, isPending } = useDeleteIncomesList();
const onClickHandler = () => {
setIsOpen(true);
};
const onCloseModalHandler = () => {
setIsOpen(false);
};
const onFormSubmitHandler = async () => {
await deleteIncomeListAsync({ id });
setIsOpen(false);
};
return {
isOpen,
isPending,
onClickHandler,
onCloseModalHandler,
onFormSubmitHandler,
};
};

View File

@ -0,0 +1 @@
export { default as DeleteIncomeListButton } from './ui/DeleteIncomeListButton.ui';

View File

@ -0,0 +1,75 @@
import { render, screen, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { http, HttpResponse } from 'msw';
import { server } from '@mocks/jest/server';
import { createReactQueryWrapper } from '@mocks/jest/wrappers';
import DeleteIncomeListButton from './DeleteIncomeListButton.ui';
import type { DeleteExpenseListPath } from '@/entity/expenses/list';
import type { IncomeList } from '@/entity/incomes/list';
const deletingList: IncomeList = {
id: '10cc093c-3875-4d33-a40a-df526266e262',
title: 'Mock list',
items: [],
updatedAt: '2025-08-29T07:57:40.503Z',
createdAt: '2025-08-29T07:57:40.503Z',
};
const renderButton = () => {
render(
<DeleteIncomeListButton data-testid="delete-income-list-button" list={deletingList} />,
{ wrapper: createReactQueryWrapper() },
);
const buttonElem = screen.getByTestId('delete-income-list-button');
return {
buttonElem,
};
};
const openModal = async () => {
const { buttonElem } = renderButton();
await userEvent.click(buttonElem);
const modalElem = screen.getByRole('dialog');
return {
modalElem,
};
};
const submitHandler = http.delete<DeleteExpenseListPath>(`/incomes/list/${deletingList.id}`, () => HttpResponse.json(deletingList));
describe('Test DeleteIncomeListButton', () => {
test('should correct render button', () => {
const { buttonElem } = renderButton();
expect(buttonElem).toBeInTheDocument();
expect(buttonElem).toBeVisible();
expect(buttonElem).toBeEnabled();
expect(buttonElem).toHaveTextContent('Удалить');
const modal = screen.queryByRole('dialog');
expect(modal).not.toBeInTheDocument();
});
test('should open modal after click', async () => {
const { modalElem } = await openModal();
expect(modalElem).toBeInTheDocument();
expect(modalElem).toBeVisible();
});
test('should close modal after submit', async () => {
server.use(submitHandler);
const { modalElem } = await openModal();
const submitButtonElem = within(modalElem).getByText('Удалить');
expect(submitButtonElem).toBeEnabled();
await userEvent.click(submitButtonElem);
expect(modalElem).not.toBeInTheDocument();
});
});

View File

@ -0,0 +1,42 @@
import { DeleteIncomeListForm } from '@/entity/incomes/list';
import { Modal } from '@/shared/ui/Modal';
import { useIncomeListButton } from '../hooks/useIncomeListButton';
import type { IncomeList } from '@/entity/incomes/list';
import type { ButtonHTMLAttributes, Ref } from 'react';
type DeleteIncomeListButtonProps = ButtonHTMLAttributes<HTMLButtonElement> & {
list: IncomeList;
ref?: Ref<HTMLButtonElement>;
};
const DeleteIncomeListButton = (props: DeleteIncomeListButtonProps) => {
const { list, ...restProps } = props;
const { id } = list;
const {
isOpen,
isPending,
onCloseModalHandler,
onClickHandler,
onFormSubmitHandler,
} = useIncomeListButton({ id });
return (
<>
<button type="button" onClick={onClickHandler} {...restProps}>
Удалить
</button>
<Modal open={isOpen} onClose={onCloseModalHandler}>
<DeleteIncomeListForm
disabled={isPending}
list={list}
onSubmit={onFormSubmitHandler}
/>
</Modal>
</>
);
};
export default DeleteIncomeListButton;

View File

@ -0,0 +1,40 @@
import { useState } from 'react';
import { useEditIncomesList } from '@/entity/incomes/list';
import type { EditIncomeListFormValues, IncomeList } from '@/entity/incomes/list';
type UseEditIncomeListButtonArgs = {
id: IncomeList['id'];
};
export const useIncomeListButton = (args: UseEditIncomeListButtonArgs) => {
const { id } = args;
const [isOpen, setIsOpen] = useState(false);
const { mutateAsync: editIncomeListAsync, isPending } = useEditIncomesList();
const onClickHandler = () => {
setIsOpen(true);
};
const onCloseModalHandler = () => {
setIsOpen(false);
};
const onFormSubmitHandler = async (data: EditIncomeListFormValues) => {
await editIncomeListAsync({
path: { id },
data,
});
setIsOpen(false);
};
return {
isOpen,
isPending,
onClickHandler,
onCloseModalHandler,
onFormSubmitHandler,
};
};

View File

@ -0,0 +1 @@
export { default as EditIncomeListButton } from './ui/EditIncomeListButton.ui';

View File

@ -0,0 +1,116 @@
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { http, HttpResponse } from 'msw';
import { server } from '@mocks/jest/server';
import { createReactQueryWrapper } from '@mocks/jest/wrappers';
import EditIncomeListButton from './EditIncomeListButton.ui';
import type {
EditExpenseListBody,
EditExpenseListPath,
EditExpenseListSuccessResponse,
} from '@/entity/expenses/list';
const editingList = {
id: '10cc093c-3875-4d33-a40a-df526266e262',
title: 'Mock list',
items: null,
updatedAt: '2025-08-29T07:57:40.503Z',
createdAt: '2025-08-29T07:57:40.503Z',
};
const renderButton = () => {
render(
<EditIncomeListButton data-testid="edit-expense-list-button" list={editingList} />,
{ wrapper: createReactQueryWrapper() },
);
const buttonElem = screen.getByTestId('edit-expense-list-button');
return {
buttonElem,
};
};
const openModal = async () => {
const { buttonElem } = renderButton();
await userEvent.click(buttonElem);
const modalElem = screen.getByRole('dialog');
return {
modalElem,
};
};
const submitHandler = (requestSpy: jest.Mock) => http.patch<
EditExpenseListPath,
EditExpenseListBody,
EditExpenseListSuccessResponse
>(
`/incomes/list/${editingList.id}`,
async ({ request }) => {
const body = await request.json();
requestSpy(body);
return HttpResponse.json(editingList);
},
);
const fillForm = async (data: { title: string }) => {
const titleInputElem = screen.getByLabelText('Название');
await userEvent.clear(titleInputElem);
await userEvent.type(titleInputElem, data.title);
};
describe('Test EditIncomeListButton', () => {
test('should correct render button', () => {
const { buttonElem } = renderButton();
expect(buttonElem).toBeInTheDocument();
expect(buttonElem).toBeVisible();
expect(buttonElem).toBeEnabled();
expect(buttonElem).toHaveTextContent('Редактировать');
const modal = screen.queryByRole('dialog');
expect(modal).not.toBeInTheDocument();
});
test('should open modal after click', async () => {
const { modalElem } = await openModal();
expect(modalElem).toBeInTheDocument();
expect(modalElem).toBeVisible();
});
describe('should close modal after submit and send request', () => {
let modalElem: HTMLElement;
let submitButtonElem: HTMLElement;
const requestSpy = jest.fn();
beforeEach(async () => {
server.use(submitHandler(requestSpy));
modalElem = (await openModal()).modalElem;
submitButtonElem = screen.getByText('Сохранить');
await fillForm({
title: 'Название списка',
});
});
test('should send correct request', async () => {
await userEvent.click(submitButtonElem);
expect(requestSpy).toHaveBeenCalledTimes(1);
expect(requestSpy).toHaveBeenCalledWith({
title: 'Название списка',
});
});
test('should close modal after submit', async () => {
expect(submitButtonElem).toBeEnabled();
await userEvent.click(submitButtonElem);
expect(modalElem).not.toBeInTheDocument();
});
});
});

View File

@ -0,0 +1,38 @@
import { EditIncomeListForm } from '@/entity/incomes/list';
import { Modal } from '@/shared/ui/Modal';
import { useIncomeListButton } from '../hooks/useIncomeListButton';
import type { IncomeList } from '@/entity/incomes/list';
import type { ButtonHTMLAttributes, FC, Ref } from 'react';
type EditIncomeListButtonProps = ButtonHTMLAttributes<HTMLButtonElement> & {
list: IncomeList;
ref?: Ref<HTMLButtonElement>;
};
const EditIncomeListButton: FC<EditIncomeListButtonProps> = (props) => {
const { list, ...restProps } = props;
const {
isOpen,
isPending,
onCloseModalHandler,
onClickHandler,
onFormSubmitHandler,
} = useIncomeListButton({ id: list.id });
return (
<>
<button type="button" onClick={onClickHandler} {...restProps}>
Редактировать
</button>
<Modal open={isOpen} onClose={onCloseModalHandler}>
<EditIncomeListForm disabled={isPending} list={list} onSubmit={onFormSubmitHandler} />
</Modal>
</>
);
};
export default EditIncomeListButton;

View File

@ -0,0 +1 @@
export { default as IncomePage } from './ui/IncomePage.ui';

View File

@ -0,0 +1,45 @@
import { useParams } from 'react-router';
import { useIncomeList } from '@/entity/incomes/list';
import { AddIncomeItemButton } from '@/features/incomes/items/AddIncomeItemButton';
import { Spinner } from '@/shared/ui/Spinner';
import { AppLayout } from '@/widgets/AppLayout';
import { IncomeItemsTable } from '@/widgets/incomes/IncomeItemsTable';
import type { IncomePageParams } from '@/shared/router';
const IncomePage = () => {
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
const { id } = useParams() as IncomePageParams;
const { data: incomeList, isPending } = useIncomeList(id);
const renderContent = () => {
if (isPending) {
return <Spinner />;
}
if (incomeList?.items?.length && incomeList.items.length > 0) {
return (
<IncomeItemsTable list={incomeList} />
);
}
return <p>Списков дохода нет</p>;
};
return (
<AppLayout>
<div>
<h1>Списки расходов</h1>
<AddIncomeItemButton listId={id} />
{renderContent()}
</div>
</AppLayout>
);
};
export default IncomePage;

View File

@ -0,0 +1 @@
export { default as IncomesPage } from './ui/IncomesPage.ui';

View File

@ -0,0 +1,38 @@
import { useIncomesList } from '@/entity/incomes/list';
import { AddIncomeListButton } from '@/features/incomes/list/AddIncomeListButton';
import { Spinner } from '@/shared/ui/Spinner';
import { AppLayout } from '@/widgets/AppLayout';
import { IncomeListTable } from '@/widgets/incomes/IncomeListTable';
const IncomesPage = () => {
const { data: incomesList, isPending } = useIncomesList();
const renderContent = () => {
if (isPending) {
return <Spinner />;
}
if (incomesList && incomesList.length > 0) {
return <IncomeListTable />;
}
return <p>Списков дохода нет</p>;
};
return (
<AppLayout>
<div>
<h1>Списки дохода</h1>
<div>
<AddIncomeListButton />
</div>
{renderContent()}
</div>
</AppLayout>
);
};
export default IncomesPage;

View File

@ -4,5 +4,7 @@ export enum QueryKeys {
BankCard = 'BankCard',
ExpensesList = 'ExpensesList',
ExpenseList = 'ExpenseList',
IncomesList = 'IncomesList',
IncomeList = 'IncomeList',
Cash = 'Cash',
}

View File

@ -305,6 +305,106 @@ export interface paths {
patch: operations["CashController_editCash"];
trace?: never;
};
"/incomes/list": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/**
* Найти все листы доходов
* @description Найти все листы доходов
*/
get: operations["IncomesController_findAllLists"];
put?: never;
/**
* Создать лист доходов
* @description Создать лист доходов
*/
post: operations["IncomesController_createList"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/incomes/list/{id}": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/**
* Найти лист доходов
* @description Найти лист доходов
*/
get: operations["IncomesController_findList"];
put?: never;
post?: never;
/**
* Удалить лист доходов
* @description Удалить лист доходов
*/
delete: operations["IncomesController_deleteList"];
options?: never;
head?: never;
/**
* Редактировать лист доходов
* @description Редактировать лист доходов
*/
patch: operations["IncomesController_editList"];
trace?: never;
};
"/incomes/list/{id}/items": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
/**
* Создать элемент дохода
* @description Создать элемент дохода
*/
post: operations["IncomesController_createListItem"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/incomes/list/{listId}/items/{id}": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/**
* Найти элемент дохода
* @description Найти элемент дохода
*/
get: operations["IncomesController_findListItem"];
put?: never;
post?: never;
/**
* Удалить элемент дохода
* @description Удалить элемент дохода
*/
delete: operations["IncomesController_deleteListItem"];
options?: never;
head?: never;
/**
* Редактировать элемент дохода
* @description Редактировать элемент дохода
*/
patch: operations["IncomesController_editListItem"];
trace?: never;
};
}
export type webhooks = Record<string, never>;
export interface components {
@ -630,6 +730,143 @@ export interface components {
*/
balance: number;
};
CreateIncomeListDto: {
/**
* @description Название листа
* @example Ежедневные покупки
*/
title: string;
};
IncomeListItemDto: {
/** @description ID */
id: string;
/** @description ID списка */
incomeListId: string;
/**
* @description Заголовок дохода
* @example Кофе
*/
title: string;
/**
* @description Описание
* @example Перед работой
*/
description: string | null;
/**
* Format: date-time
* @description Дата
* @example 2025-08-22T04:12:03.726Z
*/
date: string;
/**
* @description Сумма дохода
* @example 175.5
*/
amount: number;
/**
* @description Валюта дохода
* @default RUB
* @example RUB
*/
currency: string | null;
/**
* Format: date-time
* @description Дата создания
*/
createdAt: string;
/**
* Format: date-time
* @description Дата обновления
*/
updatedAt: string;
};
IncomeListDto: {
/** @description ID */
id: string;
/** @description Название листа */
title: string;
/** @description Список доходов */
items: components["schemas"]["IncomeListItemDto"][] | null;
/**
* Format: date-time
* @description Дата создания
*/
createdAt: string;
/**
* Format: date-time
* @description Дата обновления
*/
updatedAt: string;
};
EditIncomeListDto: {
/**
* @description Название листа
* @example Ежедневные покупки
*/
title: string;
};
DeletedIncomeListDto: {
/** @description ID */
id: string;
};
CreateIncomeListItemDto: {
/**
* @description Заголовок дохода
* @example Кофе
*/
title: string;
/**
* @description Описание
* @example Перед работой
*/
description: string | null;
/**
* Format: date-time
* @description Дата
* @example 2025-08-22T04:12:03.726Z
*/
date: string;
/**
* @description Сумма дохода
* @example 175.5
*/
amount: number;
/**
* @description Валюта дохода
* @default RUB
* @example RUB
*/
currency: string | null;
};
EditIncomeListItemDto: {
/**
* @description Заголовок дохода
* @example Кофе
*/
title: string;
/**
* @description Описание
* @example Перед работой
*/
description: string | null;
/**
* Format: date-time
* @description Дата
* @example 2025-08-22T04:12:03.726Z
*/
date: string;
/**
* @description Сумма дохода
* @example 175.5
*/
amount: number;
/**
* @description Валюта дохода
* @default RUB
* @example RUB
*/
currency: string | null;
};
};
responses: never;
parameters: never;
@ -1335,4 +1572,269 @@ export interface operations {
};
};
};
IncomesController_findAllLists: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Успешно создан лист доходов */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["IncomeListDto"][];
};
};
};
};
IncomesController_createList: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["CreateIncomeListDto"];
};
};
responses: {
/** @description Успешно создан лист доходов */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["IncomeListDto"];
};
};
};
};
IncomesController_findList: {
parameters: {
query?: never;
header?: never;
path: {
/** @description ID листа доходов */
id: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Успешно создан лист доходов */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["IncomeListDto"];
};
};
/** @description Лист доходов не найден */
404: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
IncomesController_deleteList: {
parameters: {
query?: never;
header?: never;
path: {
/** @description ID листа доходов */
id: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Успешно удален лист доходов */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["DeletedIncomeListDto"];
};
};
/** @description Лист доходов не найден */
404: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
IncomesController_editList: {
parameters: {
query?: never;
header?: never;
path: {
/** @description ID листа доходов */
id: string;
};
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["EditIncomeListDto"];
};
};
responses: {
/** @description Успешно изменен лист доходов */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["IncomeListDto"];
};
};
/** @description Лист доходов не найден */
404: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
IncomesController_createListItem: {
parameters: {
query?: never;
header?: never;
path: {
/** @description ID листа доходов */
id: string;
};
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["CreateIncomeListItemDto"];
};
};
responses: {
/** @description Успешно создан элемент дохода */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["IncomeListItemDto"];
};
};
};
};
IncomesController_findListItem: {
parameters: {
query?: never;
header?: never;
path: {
/** @description ID листа доходов */
listId: string;
/** @description ID элемента */
id: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Успешно найден элемент дохода */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["IncomeListItemDto"];
};
};
/** @description Позиция доходов не найдена */
404: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
IncomesController_deleteListItem: {
parameters: {
query?: never;
header?: never;
path: {
/** @description ID листа доходов */
listId: string;
/** @description ID элемента */
id: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Успешно удален элемент дохода */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["IncomeListItemDto"];
};
};
/** @description Позиция доходов не найдена */
404: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
IncomesController_editListItem: {
parameters: {
query?: never;
header?: never;
path: {
/** @description ID листа доходов */
listId: string;
/** @description ID элемента */
id: string;
};
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["EditIncomeListItemDto"];
};
};
responses: {
/** @description Успешно изменен элемент дохода */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["IncomeListItemDto"];
};
};
/** @description Позиция доходов не найдена */
404: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
}

View File

@ -1,2 +1,2 @@
export { PAGES } from './types';
export type { ExpensePageParams } from './types';
export type { ExpensePageParams, IncomePageParams } from './types';

View File

@ -4,6 +4,8 @@ export enum PAGES {
Register = '/register',
Expenses = '/expenses',
Expense = '/expenses/:id',
Incomes = '/incomes',
Income = '/incomes/:id',
BankCards = '/bank-cards',
Cash = '/cash',
}
@ -11,3 +13,7 @@ export enum PAGES {
export type ExpensePageParams = {
id: string;
};
export type IncomePageParams = {
id: string;
};

View File

@ -0,0 +1,5 @@
const dateFormater = new Intl.DateTimeFormat('ru-RU');
export function formatDate(date: Date): string {
return dateFormater.format(date);
}

View File

@ -3,3 +3,4 @@ export const wait = async (time: number) => new Promise((res) => {
});
export { formatRub } from './formatters/currency';
export { formatDate } from './formatters/date';

View File

@ -13,6 +13,12 @@ const SideBar = () => (
</NavLink>
</li>
<li>
<NavLink to={PAGES.Incomes}>
Доходы
</NavLink>
</li>
<li>
<NavLink to={PAGES.Expenses}>
Расходы

View File

@ -0,0 +1 @@
export { default as IncomeItemsTable } from './ui/IncomeItemsTable.ui';

View File

@ -0,0 +1,76 @@
import { DeleteIncomeItemButton } from '@/features/incomes/items/DeleteIncomeItemButton';
import { EditIncomeItemButton } from '@/features/incomes/items/EditIncomeItemButton';
import { formatDate, formatRub } from '@/shared/utils';
import classes from '@/widgets/expenses/ExpensesTable/ui/ExpensesTable.module.css';
import type { IncomeList } from '@/entity/incomes/list';
import type { FC } from 'react';
type IncomeItemsTableProps = {
list: IncomeList;
};
const IncomeItemsTable: FC<IncomeItemsTableProps> = (props) => {
const { list } = props;
const { items } = list;
return (
<table className={classes.table}>
<thead>
<tr>
<th>Идентификатор</th>
<th>Название</th>
<th>Описание</th>
<th>Дата</th>
<th>Сумма</th>
<th>Валюта</th>
<th>Действия</th>
</tr>
</thead>
<tbody>
{items?.map((item) => (
<tr key={item.id}>
<td>
{item.id}
</td>
<td>
{item.title}
</td>
<td>
{item.description}
</td>
<td>
{formatDate(new Date(item.date))}
</td>
<td>
{formatRub(item.amount)}
</td>
<td>
{item.currency}
</td>
<td>
<EditIncomeItemButton item={item} />
<DeleteIncomeItemButton item={item} />
</td>
</tr>
))}
</tbody>
</table>
);
};
export default IncomeItemsTable;

View File

@ -0,0 +1 @@
export { default as IncomeListTable } from './ui/IncomeListTable.ui';

View File

@ -0,0 +1,3 @@
.table, th, td {
border: 1px solid;
}

View File

@ -0,0 +1,67 @@
import { NavLink } from 'react-router';
import { useIncomesList } from '@/entity/incomes/list';
import { DeleteIncomeListButton } from '@/features/incomes/list/DeleteIncomeListButton';
import { EditIncomeListButton } from '@/features/incomes/list/EditIncomeListButton';
import classes from './IncomeListTable.module.css';
const IncomeListTable = () => {
const { data: incomesList, isPending, isLoading } = useIncomesList();
if (isLoading || isPending) {
return (
<span>Loading...</span>
);
}
return (
<table className={classes.table}>
<thead>
<tr>
<th>Перейти</th>
<th>Идентификатор</th>
<th>Название</th>
<th>Количество элементов</th>
<th>Действия</th>
</tr>
</thead>
<tbody>
{incomesList?.map((income) => (
<tr key={income.id}>
<td>
<NavLink to={`/incomes/${income.id}`}>
Ссылка
</NavLink>
</td>
<td>
{income.id}
</td>
<td>
{income.title}
</td>
<td>
{income.items?.length ?? 0}
</td>
<td>
<EditIncomeListButton list={income} />
<DeleteIncomeListButton list={income} />
</td>
</tr>
))}
</tbody>
</table>
);
};
export default IncomeListTable;