feat(frontend): add income
This commit is contained in:
parent
ee6276e8ab
commit
9a7d444d9d
@ -6,6 +6,8 @@ import { CashPage } from '@/pages/Cash';
|
|||||||
import { ExpensePage } from '@/pages/Expense';
|
import { ExpensePage } from '@/pages/Expense';
|
||||||
import { ExpensesPage } from '@/pages/Expenses';
|
import { ExpensesPage } from '@/pages/Expenses';
|
||||||
import { HomePage } from '@/pages/Home';
|
import { HomePage } from '@/pages/Home';
|
||||||
|
import { IncomePage } from '@/pages/Income';
|
||||||
|
import { IncomesPage } from '@/pages/Incomes';
|
||||||
import { PAGES } from '@/shared/router';
|
import { PAGES } from '@/shared/router';
|
||||||
import { AuthRoute } from '@/shared/ui/AuthRoute';
|
import { AuthRoute } from '@/shared/ui/AuthRoute';
|
||||||
|
|
||||||
@ -19,6 +21,10 @@ const Routing = () => (
|
|||||||
|
|
||||||
<Route element={<ExpensePage />} path={PAGES.Expense} />
|
<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={<BankCardsPage />} path={PAGES.BankCards} />
|
||||||
|
|
||||||
<Route element={<CashPage />} path={PAGES.Cash} />
|
<Route element={<CashPage />} path={PAGES.Cash} />
|
||||||
|
|||||||
13
frontend/src/entity/incomes/item/api/addIncomeItem.ts
Normal file
13
frontend/src/entity/incomes/item/api/addIncomeItem.ts
Normal 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;
|
||||||
|
}
|
||||||
12
frontend/src/entity/incomes/item/api/deleteIncomeItem.ts
Normal file
12
frontend/src/entity/incomes/item/api/deleteIncomeItem.ts
Normal 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;
|
||||||
|
}
|
||||||
13
frontend/src/entity/incomes/item/api/editIncomeItem.ts
Normal file
13
frontend/src/entity/incomes/item/api/editIncomeItem.ts
Normal 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;
|
||||||
|
}
|
||||||
17
frontend/src/entity/incomes/item/hooks/useAddIncomeItem.ts
Normal file
17
frontend/src/entity/incomes/item/hooks/useAddIncomeItem.ts
Normal 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] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
@ -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] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
17
frontend/src/entity/incomes/item/hooks/useEditIncomeItem.ts
Normal file
17
frontend/src/entity/incomes/item/hooks/useEditIncomeItem.ts
Normal 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] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
17
frontend/src/entity/incomes/item/index.ts
Normal file
17
frontend/src/entity/incomes/item/index.ts
Normal 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';
|
||||||
3
frontend/src/entity/incomes/item/types.ts
Normal file
3
frontend/src/entity/incomes/item/types.ts
Normal 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];
|
||||||
@ -0,0 +1,5 @@
|
|||||||
|
.form {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
@ -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',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -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;
|
||||||
@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -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;
|
||||||
@ -0,0 +1,5 @@
|
|||||||
|
.form {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
@ -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',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -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;
|
||||||
13
frontend/src/entity/incomes/list/api/addIncomeList.ts
Normal file
13
frontend/src/entity/incomes/list/api/addIncomeList.ts
Normal 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;
|
||||||
|
}
|
||||||
13
frontend/src/entity/incomes/list/api/deleteIncomeList.ts
Normal file
13
frontend/src/entity/incomes/list/api/deleteIncomeList.ts
Normal 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;
|
||||||
|
}
|
||||||
17
frontend/src/entity/incomes/list/api/editIncomeList.ts
Normal file
17
frontend/src/entity/incomes/list/api/editIncomeList.ts
Normal 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;
|
||||||
|
}
|
||||||
12
frontend/src/entity/incomes/list/api/getIncomeList.ts
Normal file
12
frontend/src/entity/incomes/list/api/getIncomeList.ts
Normal 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;
|
||||||
|
}
|
||||||
11
frontend/src/entity/incomes/list/api/getIncomeLists.ts
Normal file
11
frontend/src/entity/incomes/list/api/getIncomeLists.ts
Normal 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;
|
||||||
|
}
|
||||||
17
frontend/src/entity/incomes/list/hooks/useAddIncomesList.ts
Normal file
17
frontend/src/entity/incomes/list/hooks/useAddIncomesList.ts
Normal 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] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
@ -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] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
17
frontend/src/entity/incomes/list/hooks/useEditIncomesList.ts
Normal file
17
frontend/src/entity/incomes/list/hooks/useEditIncomesList.ts
Normal 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] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
12
frontend/src/entity/incomes/list/hooks/useIncomeList.ts
Normal file
12
frontend/src/entity/incomes/list/hooks/useIncomeList.ts
Normal 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],
|
||||||
|
});
|
||||||
10
frontend/src/entity/incomes/list/hooks/useIncomesList.ts
Normal file
10
frontend/src/entity/incomes/list/hooks/useIncomesList.ts
Normal 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],
|
||||||
|
});
|
||||||
19
frontend/src/entity/incomes/list/index.ts
Normal file
19
frontend/src/entity/incomes/list/index.ts
Normal 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';
|
||||||
3
frontend/src/entity/incomes/list/types.ts
Normal file
3
frontend/src/entity/incomes/list/types.ts
Normal 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'];
|
||||||
@ -0,0 +1,5 @@
|
|||||||
|
.form {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
@ -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: 'Название списка',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -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;
|
||||||
@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -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;
|
||||||
@ -0,0 +1,5 @@
|
|||||||
|
.form {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
@ -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: 'Название списка',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -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;
|
||||||
@ -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,
|
||||||
|
};
|
||||||
|
};
|
||||||
@ -0,0 +1 @@
|
|||||||
|
export { default as AddIncomeItemButton } from './ui/AddIncomeItemButton.ui';
|
||||||
@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -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;
|
||||||
@ -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,
|
||||||
|
};
|
||||||
|
};
|
||||||
@ -0,0 +1 @@
|
|||||||
|
export { default as DeleteIncomeItemButton } from './ui/DeleteIncomeItemButton.ui';
|
||||||
@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -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;
|
||||||
@ -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,
|
||||||
|
};
|
||||||
|
};
|
||||||
@ -0,0 +1 @@
|
|||||||
|
export { default as EditIncomeItemButton } from './ui/EditIncomeItemButton.ui';
|
||||||
@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -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;
|
||||||
@ -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,
|
||||||
|
};
|
||||||
|
};
|
||||||
@ -0,0 +1 @@
|
|||||||
|
export { default as AddIncomeListButton } from './ui/AddIncomeListButton.ui';
|
||||||
@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -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;
|
||||||
@ -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,
|
||||||
|
};
|
||||||
|
};
|
||||||
@ -0,0 +1 @@
|
|||||||
|
export { default as DeleteIncomeListButton } from './ui/DeleteIncomeListButton.ui';
|
||||||
@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -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;
|
||||||
@ -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,
|
||||||
|
};
|
||||||
|
};
|
||||||
@ -0,0 +1 @@
|
|||||||
|
export { default as EditIncomeListButton } from './ui/EditIncomeListButton.ui';
|
||||||
@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -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;
|
||||||
1
frontend/src/pages/Income/index.ts
Normal file
1
frontend/src/pages/Income/index.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export { default as IncomePage } from './ui/IncomePage.ui';
|
||||||
45
frontend/src/pages/Income/ui/IncomePage.ui.tsx
Normal file
45
frontend/src/pages/Income/ui/IncomePage.ui.tsx
Normal 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;
|
||||||
1
frontend/src/pages/Incomes/index.ts
Normal file
1
frontend/src/pages/Incomes/index.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export { default as IncomesPage } from './ui/IncomesPage.ui';
|
||||||
38
frontend/src/pages/Incomes/ui/IncomesPage.ui.tsx
Normal file
38
frontend/src/pages/Incomes/ui/IncomesPage.ui.tsx
Normal 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;
|
||||||
@ -4,5 +4,7 @@ export enum QueryKeys {
|
|||||||
BankCard = 'BankCard',
|
BankCard = 'BankCard',
|
||||||
ExpensesList = 'ExpensesList',
|
ExpensesList = 'ExpensesList',
|
||||||
ExpenseList = 'ExpenseList',
|
ExpenseList = 'ExpenseList',
|
||||||
|
IncomesList = 'IncomesList',
|
||||||
|
IncomeList = 'IncomeList',
|
||||||
Cash = 'Cash',
|
Cash = 'Cash',
|
||||||
}
|
}
|
||||||
|
|||||||
@ -305,6 +305,106 @@ export interface paths {
|
|||||||
patch: operations["CashController_editCash"];
|
patch: operations["CashController_editCash"];
|
||||||
trace?: never;
|
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 type webhooks = Record<string, never>;
|
||||||
export interface components {
|
export interface components {
|
||||||
@ -630,6 +730,143 @@ export interface components {
|
|||||||
*/
|
*/
|
||||||
balance: number;
|
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;
|
responses: never;
|
||||||
parameters: 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;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,2 +1,2 @@
|
|||||||
export { PAGES } from './types';
|
export { PAGES } from './types';
|
||||||
export type { ExpensePageParams } from './types';
|
export type { ExpensePageParams, IncomePageParams } from './types';
|
||||||
|
|||||||
@ -4,6 +4,8 @@ export enum PAGES {
|
|||||||
Register = '/register',
|
Register = '/register',
|
||||||
Expenses = '/expenses',
|
Expenses = '/expenses',
|
||||||
Expense = '/expenses/:id',
|
Expense = '/expenses/:id',
|
||||||
|
Incomes = '/incomes',
|
||||||
|
Income = '/incomes/:id',
|
||||||
BankCards = '/bank-cards',
|
BankCards = '/bank-cards',
|
||||||
Cash = '/cash',
|
Cash = '/cash',
|
||||||
}
|
}
|
||||||
@ -11,3 +13,7 @@ export enum PAGES {
|
|||||||
export type ExpensePageParams = {
|
export type ExpensePageParams = {
|
||||||
id: string;
|
id: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type IncomePageParams = {
|
||||||
|
id: string;
|
||||||
|
};
|
||||||
|
|||||||
5
frontend/src/shared/utils/formatters/date.ts
Normal file
5
frontend/src/shared/utils/formatters/date.ts
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
const dateFormater = new Intl.DateTimeFormat('ru-RU');
|
||||||
|
|
||||||
|
export function formatDate(date: Date): string {
|
||||||
|
return dateFormater.format(date);
|
||||||
|
}
|
||||||
@ -3,3 +3,4 @@ export const wait = async (time: number) => new Promise((res) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
export { formatRub } from './formatters/currency';
|
export { formatRub } from './formatters/currency';
|
||||||
|
export { formatDate } from './formatters/date';
|
||||||
|
|||||||
@ -13,6 +13,12 @@ const SideBar = () => (
|
|||||||
</NavLink>
|
</NavLink>
|
||||||
</li>
|
</li>
|
||||||
|
|
||||||
|
<li>
|
||||||
|
<NavLink to={PAGES.Incomes}>
|
||||||
|
Доходы
|
||||||
|
</NavLink>
|
||||||
|
</li>
|
||||||
|
|
||||||
<li>
|
<li>
|
||||||
<NavLink to={PAGES.Expenses}>
|
<NavLink to={PAGES.Expenses}>
|
||||||
Расходы
|
Расходы
|
||||||
|
|||||||
1
frontend/src/widgets/incomes/IncomeItemsTable/index.ts
Normal file
1
frontend/src/widgets/incomes/IncomeItemsTable/index.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export { default as IncomeItemsTable } from './ui/IncomeItemsTable.ui';
|
||||||
@ -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;
|
||||||
1
frontend/src/widgets/incomes/IncomeListTable/index.ts
Normal file
1
frontend/src/widgets/incomes/IncomeListTable/index.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export { default as IncomeListTable } from './ui/IncomeListTable.ui';
|
||||||
@ -0,0 +1,3 @@
|
|||||||
|
.table, th, td {
|
||||||
|
border: 1px solid;
|
||||||
|
}
|
||||||
@ -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;
|
||||||
Loading…
x
Reference in New Issue
Block a user