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 { ExpensesPage } from '@/pages/Expenses';
|
||||
import { HomePage } from '@/pages/Home';
|
||||
import { IncomePage } from '@/pages/Income';
|
||||
import { IncomesPage } from '@/pages/Incomes';
|
||||
import { PAGES } from '@/shared/router';
|
||||
import { AuthRoute } from '@/shared/ui/AuthRoute';
|
||||
|
||||
@ -19,6 +21,10 @@ const Routing = () => (
|
||||
|
||||
<Route element={<ExpensePage />} path={PAGES.Expense} />
|
||||
|
||||
<Route element={<IncomesPage />} path={PAGES.Incomes} />
|
||||
|
||||
<Route element={<IncomePage />} path={PAGES.Income} />
|
||||
|
||||
<Route element={<BankCardsPage />} path={PAGES.BankCards} />
|
||||
|
||||
<Route element={<CashPage />} path={PAGES.Cash} />
|
||||
|
||||
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',
|
||||
ExpensesList = 'ExpensesList',
|
||||
ExpenseList = 'ExpenseList',
|
||||
IncomesList = 'IncomesList',
|
||||
IncomeList = 'IncomeList',
|
||||
Cash = 'Cash',
|
||||
}
|
||||
|
||||
@ -305,6 +305,106 @@ export interface paths {
|
||||
patch: operations["CashController_editCash"];
|
||||
trace?: never;
|
||||
};
|
||||
"/incomes/list": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
/**
|
||||
* Найти все листы доходов
|
||||
* @description Найти все листы доходов
|
||||
*/
|
||||
get: operations["IncomesController_findAllLists"];
|
||||
put?: never;
|
||||
/**
|
||||
* Создать лист доходов
|
||||
* @description Создать лист доходов
|
||||
*/
|
||||
post: operations["IncomesController_createList"];
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/incomes/list/{id}": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
/**
|
||||
* Найти лист доходов
|
||||
* @description Найти лист доходов
|
||||
*/
|
||||
get: operations["IncomesController_findList"];
|
||||
put?: never;
|
||||
post?: never;
|
||||
/**
|
||||
* Удалить лист доходов
|
||||
* @description Удалить лист доходов
|
||||
*/
|
||||
delete: operations["IncomesController_deleteList"];
|
||||
options?: never;
|
||||
head?: never;
|
||||
/**
|
||||
* Редактировать лист доходов
|
||||
* @description Редактировать лист доходов
|
||||
*/
|
||||
patch: operations["IncomesController_editList"];
|
||||
trace?: never;
|
||||
};
|
||||
"/incomes/list/{id}/items": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get?: never;
|
||||
put?: never;
|
||||
/**
|
||||
* Создать элемент дохода
|
||||
* @description Создать элемент дохода
|
||||
*/
|
||||
post: operations["IncomesController_createListItem"];
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/incomes/list/{listId}/items/{id}": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
/**
|
||||
* Найти элемент дохода
|
||||
* @description Найти элемент дохода
|
||||
*/
|
||||
get: operations["IncomesController_findListItem"];
|
||||
put?: never;
|
||||
post?: never;
|
||||
/**
|
||||
* Удалить элемент дохода
|
||||
* @description Удалить элемент дохода
|
||||
*/
|
||||
delete: operations["IncomesController_deleteListItem"];
|
||||
options?: never;
|
||||
head?: never;
|
||||
/**
|
||||
* Редактировать элемент дохода
|
||||
* @description Редактировать элемент дохода
|
||||
*/
|
||||
patch: operations["IncomesController_editListItem"];
|
||||
trace?: never;
|
||||
};
|
||||
}
|
||||
export type webhooks = Record<string, never>;
|
||||
export interface components {
|
||||
@ -630,6 +730,143 @@ export interface components {
|
||||
*/
|
||||
balance: number;
|
||||
};
|
||||
CreateIncomeListDto: {
|
||||
/**
|
||||
* @description Название листа
|
||||
* @example Ежедневные покупки
|
||||
*/
|
||||
title: string;
|
||||
};
|
||||
IncomeListItemDto: {
|
||||
/** @description ID */
|
||||
id: string;
|
||||
/** @description ID списка */
|
||||
incomeListId: string;
|
||||
/**
|
||||
* @description Заголовок дохода
|
||||
* @example Кофе
|
||||
*/
|
||||
title: string;
|
||||
/**
|
||||
* @description Описание
|
||||
* @example Перед работой
|
||||
*/
|
||||
description: string | null;
|
||||
/**
|
||||
* Format: date-time
|
||||
* @description Дата
|
||||
* @example 2025-08-22T04:12:03.726Z
|
||||
*/
|
||||
date: string;
|
||||
/**
|
||||
* @description Сумма дохода
|
||||
* @example 175.5
|
||||
*/
|
||||
amount: number;
|
||||
/**
|
||||
* @description Валюта дохода
|
||||
* @default RUB
|
||||
* @example RUB
|
||||
*/
|
||||
currency: string | null;
|
||||
/**
|
||||
* Format: date-time
|
||||
* @description Дата создания
|
||||
*/
|
||||
createdAt: string;
|
||||
/**
|
||||
* Format: date-time
|
||||
* @description Дата обновления
|
||||
*/
|
||||
updatedAt: string;
|
||||
};
|
||||
IncomeListDto: {
|
||||
/** @description ID */
|
||||
id: string;
|
||||
/** @description Название листа */
|
||||
title: string;
|
||||
/** @description Список доходов */
|
||||
items: components["schemas"]["IncomeListItemDto"][] | null;
|
||||
/**
|
||||
* Format: date-time
|
||||
* @description Дата создания
|
||||
*/
|
||||
createdAt: string;
|
||||
/**
|
||||
* Format: date-time
|
||||
* @description Дата обновления
|
||||
*/
|
||||
updatedAt: string;
|
||||
};
|
||||
EditIncomeListDto: {
|
||||
/**
|
||||
* @description Название листа
|
||||
* @example Ежедневные покупки
|
||||
*/
|
||||
title: string;
|
||||
};
|
||||
DeletedIncomeListDto: {
|
||||
/** @description ID */
|
||||
id: string;
|
||||
};
|
||||
CreateIncomeListItemDto: {
|
||||
/**
|
||||
* @description Заголовок дохода
|
||||
* @example Кофе
|
||||
*/
|
||||
title: string;
|
||||
/**
|
||||
* @description Описание
|
||||
* @example Перед работой
|
||||
*/
|
||||
description: string | null;
|
||||
/**
|
||||
* Format: date-time
|
||||
* @description Дата
|
||||
* @example 2025-08-22T04:12:03.726Z
|
||||
*/
|
||||
date: string;
|
||||
/**
|
||||
* @description Сумма дохода
|
||||
* @example 175.5
|
||||
*/
|
||||
amount: number;
|
||||
/**
|
||||
* @description Валюта дохода
|
||||
* @default RUB
|
||||
* @example RUB
|
||||
*/
|
||||
currency: string | null;
|
||||
};
|
||||
EditIncomeListItemDto: {
|
||||
/**
|
||||
* @description Заголовок дохода
|
||||
* @example Кофе
|
||||
*/
|
||||
title: string;
|
||||
/**
|
||||
* @description Описание
|
||||
* @example Перед работой
|
||||
*/
|
||||
description: string | null;
|
||||
/**
|
||||
* Format: date-time
|
||||
* @description Дата
|
||||
* @example 2025-08-22T04:12:03.726Z
|
||||
*/
|
||||
date: string;
|
||||
/**
|
||||
* @description Сумма дохода
|
||||
* @example 175.5
|
||||
*/
|
||||
amount: number;
|
||||
/**
|
||||
* @description Валюта дохода
|
||||
* @default RUB
|
||||
* @example RUB
|
||||
*/
|
||||
currency: string | null;
|
||||
};
|
||||
};
|
||||
responses: never;
|
||||
parameters: never;
|
||||
@ -1335,4 +1572,269 @@ export interface operations {
|
||||
};
|
||||
};
|
||||
};
|
||||
IncomesController_findAllLists: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description Успешно создан лист доходов */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["IncomeListDto"][];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
IncomesController_createList: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody: {
|
||||
content: {
|
||||
"application/json": components["schemas"]["CreateIncomeListDto"];
|
||||
};
|
||||
};
|
||||
responses: {
|
||||
/** @description Успешно создан лист доходов */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["IncomeListDto"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
IncomesController_findList: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
/** @description ID листа доходов */
|
||||
id: string;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description Успешно создан лист доходов */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["IncomeListDto"];
|
||||
};
|
||||
};
|
||||
/** @description Лист доходов не найден */
|
||||
404: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content?: never;
|
||||
};
|
||||
};
|
||||
};
|
||||
IncomesController_deleteList: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
/** @description ID листа доходов */
|
||||
id: string;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description Успешно удален лист доходов */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["DeletedIncomeListDto"];
|
||||
};
|
||||
};
|
||||
/** @description Лист доходов не найден */
|
||||
404: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content?: never;
|
||||
};
|
||||
};
|
||||
};
|
||||
IncomesController_editList: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
/** @description ID листа доходов */
|
||||
id: string;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody: {
|
||||
content: {
|
||||
"application/json": components["schemas"]["EditIncomeListDto"];
|
||||
};
|
||||
};
|
||||
responses: {
|
||||
/** @description Успешно изменен лист доходов */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["IncomeListDto"];
|
||||
};
|
||||
};
|
||||
/** @description Лист доходов не найден */
|
||||
404: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content?: never;
|
||||
};
|
||||
};
|
||||
};
|
||||
IncomesController_createListItem: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
/** @description ID листа доходов */
|
||||
id: string;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody: {
|
||||
content: {
|
||||
"application/json": components["schemas"]["CreateIncomeListItemDto"];
|
||||
};
|
||||
};
|
||||
responses: {
|
||||
/** @description Успешно создан элемент дохода */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["IncomeListItemDto"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
IncomesController_findListItem: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
/** @description ID листа доходов */
|
||||
listId: string;
|
||||
/** @description ID элемента */
|
||||
id: string;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description Успешно найден элемент дохода */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["IncomeListItemDto"];
|
||||
};
|
||||
};
|
||||
/** @description Позиция доходов не найдена */
|
||||
404: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content?: never;
|
||||
};
|
||||
};
|
||||
};
|
||||
IncomesController_deleteListItem: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
/** @description ID листа доходов */
|
||||
listId: string;
|
||||
/** @description ID элемента */
|
||||
id: string;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description Успешно удален элемент дохода */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["IncomeListItemDto"];
|
||||
};
|
||||
};
|
||||
/** @description Позиция доходов не найдена */
|
||||
404: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content?: never;
|
||||
};
|
||||
};
|
||||
};
|
||||
IncomesController_editListItem: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
/** @description ID листа доходов */
|
||||
listId: string;
|
||||
/** @description ID элемента */
|
||||
id: string;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody: {
|
||||
content: {
|
||||
"application/json": components["schemas"]["EditIncomeListItemDto"];
|
||||
};
|
||||
};
|
||||
responses: {
|
||||
/** @description Успешно изменен элемент дохода */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["IncomeListItemDto"];
|
||||
};
|
||||
};
|
||||
/** @description Позиция доходов не найдена */
|
||||
404: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content?: never;
|
||||
};
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
@ -1,2 +1,2 @@
|
||||
export { PAGES } from './types';
|
||||
export type { ExpensePageParams } from './types';
|
||||
export type { ExpensePageParams, IncomePageParams } from './types';
|
||||
|
||||
@ -4,6 +4,8 @@ export enum PAGES {
|
||||
Register = '/register',
|
||||
Expenses = '/expenses',
|
||||
Expense = '/expenses/:id',
|
||||
Incomes = '/incomes',
|
||||
Income = '/incomes/:id',
|
||||
BankCards = '/bank-cards',
|
||||
Cash = '/cash',
|
||||
}
|
||||
@ -11,3 +13,7 @@ export enum PAGES {
|
||||
export type ExpensePageParams = {
|
||||
id: string;
|
||||
};
|
||||
|
||||
export type IncomePageParams = {
|
||||
id: string;
|
||||
};
|
||||
|
||||
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 { formatDate } from './formatters/date';
|
||||
|
||||
@ -13,6 +13,12 @@ const SideBar = () => (
|
||||
</NavLink>
|
||||
</li>
|
||||
|
||||
<li>
|
||||
<NavLink to={PAGES.Incomes}>
|
||||
Доходы
|
||||
</NavLink>
|
||||
</li>
|
||||
|
||||
<li>
|
||||
<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