diff --git a/frontend/src/app/App/ui/Routing.tsx b/frontend/src/app/App/ui/Routing.tsx index b1b60dc..43d1bdf 100644 --- a/frontend/src/app/App/ui/Routing.tsx +++ b/frontend/src/app/App/ui/Routing.tsx @@ -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 = () => ( } path={PAGES.Expense} /> + } path={PAGES.Incomes} /> + + } path={PAGES.Income} /> + } path={PAGES.BankCards} /> } path={PAGES.Cash} /> diff --git a/frontend/src/entity/incomes/item/api/addIncomeItem.ts b/frontend/src/entity/incomes/item/api/addIncomeItem.ts new file mode 100644 index 0000000..7d9a66c --- /dev/null +++ b/frontend/src/entity/incomes/item/api/addIncomeItem.ts @@ -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(`/incomes/list/${path.id}/items`, data); + + return result; +} diff --git a/frontend/src/entity/incomes/item/api/deleteIncomeItem.ts b/frontend/src/entity/incomes/item/api/deleteIncomeItem.ts new file mode 100644 index 0000000..183f500 --- /dev/null +++ b/frontend/src/entity/incomes/item/api/deleteIncomeItem.ts @@ -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(`/incomes/list/${path.listId}/items/${path.id}`); + + return result; +} diff --git a/frontend/src/entity/incomes/item/api/editIncomeItem.ts b/frontend/src/entity/incomes/item/api/editIncomeItem.ts new file mode 100644 index 0000000..6eabadc --- /dev/null +++ b/frontend/src/entity/incomes/item/api/editIncomeItem.ts @@ -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(`/incomes/list/${path.listId}/items/${path.id}`, data); + + return result; +} diff --git a/frontend/src/entity/incomes/item/hooks/useAddIncomeItem.ts b/frontend/src/entity/incomes/item/hooks/useAddIncomeItem.ts new file mode 100644 index 0000000..a3f6a75 --- /dev/null +++ b/frontend/src/entity/incomes/item/hooks/useAddIncomeItem.ts @@ -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] }); + }, + }); +}; diff --git a/frontend/src/entity/incomes/item/hooks/useDeleteIncomeItem.ts b/frontend/src/entity/incomes/item/hooks/useDeleteIncomeItem.ts new file mode 100644 index 0000000..27ab4f8 --- /dev/null +++ b/frontend/src/entity/incomes/item/hooks/useDeleteIncomeItem.ts @@ -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] }); + }, + }); +}; diff --git a/frontend/src/entity/incomes/item/hooks/useEditIncomeItem.ts b/frontend/src/entity/incomes/item/hooks/useEditIncomeItem.ts new file mode 100644 index 0000000..a910c8e --- /dev/null +++ b/frontend/src/entity/incomes/item/hooks/useEditIncomeItem.ts @@ -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] }); + }, + }); +}; diff --git a/frontend/src/entity/incomes/item/index.ts b/frontend/src/entity/incomes/item/index.ts new file mode 100644 index 0000000..3368861 --- /dev/null +++ b/frontend/src/entity/incomes/item/index.ts @@ -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'; diff --git a/frontend/src/entity/incomes/item/types.ts b/frontend/src/entity/incomes/item/types.ts new file mode 100644 index 0000000..63b5e5e --- /dev/null +++ b/frontend/src/entity/incomes/item/types.ts @@ -0,0 +1,3 @@ +import type { paths } from '@/shared/api/schema'; + +export type IncomeListItem = NonNullable[number]; diff --git a/frontend/src/entity/incomes/item/ui/AddIncomeItemForm/AddIncomeItemForm.module.css b/frontend/src/entity/incomes/item/ui/AddIncomeItemForm/AddIncomeItemForm.module.css new file mode 100644 index 0000000..a97ffec --- /dev/null +++ b/frontend/src/entity/incomes/item/ui/AddIncomeItemForm/AddIncomeItemForm.module.css @@ -0,0 +1,5 @@ +.form { + display: flex; + flex-direction: column; + gap: 10px; +} diff --git a/frontend/src/entity/incomes/item/ui/AddIncomeItemForm/AddIncomeItemForm.test.tsx b/frontend/src/entity/incomes/item/ui/AddIncomeItemForm/AddIncomeItemForm.test.tsx new file mode 100644 index 0000000..152581e --- /dev/null +++ b/frontend/src/entity/incomes/item/ui/AddIncomeItemForm/AddIncomeItemForm.test.tsx @@ -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) => { + const onSubmit = jest.fn(); + + render(); + + 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', + }); + }); +}); diff --git a/frontend/src/entity/incomes/item/ui/AddIncomeItemForm/AddIncomeItemForm.ui.tsx b/frontend/src/entity/incomes/item/ui/AddIncomeItemForm/AddIncomeItemForm.ui.tsx new file mode 100644 index 0000000..beb7c40 --- /dev/null +++ b/frontend/src/entity/incomes/item/ui/AddIncomeItemForm/AddIncomeItemForm.ui.tsx @@ -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, 'onSubmit'>; + +export type AddIncomeItemFormProps = BaseFormProps & { + disabled?: boolean; + onSubmit?: (data: AddIncomeItemFormValues) => Promise | void; + ref?: RefObject; +}; + +const AddIncomeItemForm: FC = (props) => { + const { + disabled, + onSubmit, + ref, + ...formProps + } = props; + + const onFormSubmitHandler: FormEventHandler = (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 ( +
+

Добавить список доходов

+ + + + + + + + + + +
+ ); +}; + +export default AddIncomeItemForm; diff --git a/frontend/src/entity/incomes/item/ui/DeleteIncomeItemForm/DeleteIncomeItemForm.test.tsx b/frontend/src/entity/incomes/item/ui/DeleteIncomeItemForm/DeleteIncomeItemForm.test.tsx new file mode 100644 index 0000000..31b9090 --- /dev/null +++ b/frontend/src/entity/incomes/item/ui/DeleteIncomeItemForm/DeleteIncomeItemForm.test.tsx @@ -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) => { + const onSubmit = jest.fn(); + + render( + , + ); + + 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); + }); +}); diff --git a/frontend/src/entity/incomes/item/ui/DeleteIncomeItemForm/DeleteIncomeItemForm.ui.tsx b/frontend/src/entity/incomes/item/ui/DeleteIncomeItemForm/DeleteIncomeItemForm.ui.tsx new file mode 100644 index 0000000..6b9473c --- /dev/null +++ b/frontend/src/entity/incomes/item/ui/DeleteIncomeItemForm/DeleteIncomeItemForm.ui.tsx @@ -0,0 +1,40 @@ +import type { IncomeListItem } from '../../types'; +import type { + FC, + FormEventHandler, + FormHTMLAttributes, + RefObject, +} from 'react'; + +type BaseFormProps = Omit, 'onSubmit'>; + +export type DeleteIncomeItemFormProps = BaseFormProps & { + item: IncomeListItem; + onSubmit?: (item: IncomeListItem) => Promise | void; + disabled?: boolean; + ref?: RefObject; +}; + +const DeleteIncomeItemForm: FC = (props) => { + const { + item, + onSubmit, + ref, + disabled, + ...restProps + } = props; + const onFormSubmitHandler: FormEventHandler = (event) => { + event.preventDefault(); + onSubmit?.(item); + }; + + return ( +
+

Удалить элемент ?

+ + +
+ ); +}; + +export default DeleteIncomeItemForm; diff --git a/frontend/src/entity/incomes/item/ui/EditIncomeItemForm/EditIncomeItemForm.module.css b/frontend/src/entity/incomes/item/ui/EditIncomeItemForm/EditIncomeItemForm.module.css new file mode 100644 index 0000000..a97ffec --- /dev/null +++ b/frontend/src/entity/incomes/item/ui/EditIncomeItemForm/EditIncomeItemForm.module.css @@ -0,0 +1,5 @@ +.form { + display: flex; + flex-direction: column; + gap: 10px; +} diff --git a/frontend/src/entity/incomes/item/ui/EditIncomeItemForm/EditIncomeItemForm.test.tsx b/frontend/src/entity/incomes/item/ui/EditIncomeItemForm/EditIncomeItemForm.test.tsx new file mode 100644 index 0000000..3ee588f --- /dev/null +++ b/frontend/src/entity/incomes/item/ui/EditIncomeItemForm/EditIncomeItemForm.test.tsx @@ -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) => { + const onSubmit = jest.fn(); + + render( + , + ); + + 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', + }); + }); +}); diff --git a/frontend/src/entity/incomes/item/ui/EditIncomeItemForm/EditIncomeItemForm.ui.tsx b/frontend/src/entity/incomes/item/ui/EditIncomeItemForm/EditIncomeItemForm.ui.tsx new file mode 100644 index 0000000..b1d15ef --- /dev/null +++ b/frontend/src/entity/incomes/item/ui/EditIncomeItemForm/EditIncomeItemForm.ui.tsx @@ -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, 'onSubmit'>; +export type EditIncomeItemFormProps = BaseFormProps & { + disabled?: boolean; + onSubmit?: (data: EditIncomeItemFormValues) => Promise | void; + ref?: RefObject; + item: IncomeListItem; +}; + +const EditIncomeItemForm: FC = (props) => { + const { + disabled, + onSubmit, + ref, + item, + ...formProps + } = props; + + // eslint-disable-next-line @typescript-eslint/no-misused-promises + const onFormSubmitHandler: FormEventHandler = 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 ( +
+

Изменить доход

+ + + + + + + + + + +
+ ); +}; + +export default EditIncomeItemForm; diff --git a/frontend/src/entity/incomes/list/api/addIncomeList.ts b/frontend/src/entity/incomes/list/api/addIncomeList.ts new file mode 100644 index 0000000..76f0b90 --- /dev/null +++ b/frontend/src/entity/incomes/list/api/addIncomeList.ts @@ -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('/incomes/list', data); + + return result; +} diff --git a/frontend/src/entity/incomes/list/api/deleteIncomeList.ts b/frontend/src/entity/incomes/list/api/deleteIncomeList.ts new file mode 100644 index 0000000..577351b --- /dev/null +++ b/frontend/src/entity/incomes/list/api/deleteIncomeList.ts @@ -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(`/incomes/list/${id}`); + + return result; +} diff --git a/frontend/src/entity/incomes/list/api/editIncomeList.ts b/frontend/src/entity/incomes/list/api/editIncomeList.ts new file mode 100644 index 0000000..c8182df --- /dev/null +++ b/frontend/src/entity/incomes/list/api/editIncomeList.ts @@ -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(`/incomes/list/${id}`, data); + + return result; +} diff --git a/frontend/src/entity/incomes/list/api/getIncomeList.ts b/frontend/src/entity/incomes/list/api/getIncomeList.ts new file mode 100644 index 0000000..0932f69 --- /dev/null +++ b/frontend/src/entity/incomes/list/api/getIncomeList.ts @@ -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(`/incomes/list/${path.id}`); + + return result; +} diff --git a/frontend/src/entity/incomes/list/api/getIncomeLists.ts b/frontend/src/entity/incomes/list/api/getIncomeLists.ts new file mode 100644 index 0000000..bdcd908 --- /dev/null +++ b/frontend/src/entity/incomes/list/api/getIncomeLists.ts @@ -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('/incomes/list'); + + return result; +} diff --git a/frontend/src/entity/incomes/list/hooks/useAddIncomesList.ts b/frontend/src/entity/incomes/list/hooks/useAddIncomesList.ts new file mode 100644 index 0000000..c581d54 --- /dev/null +++ b/frontend/src/entity/incomes/list/hooks/useAddIncomesList.ts @@ -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] }); + }, + }); +}; diff --git a/frontend/src/entity/incomes/list/hooks/useDeleteIncomesList.ts b/frontend/src/entity/incomes/list/hooks/useDeleteIncomesList.ts new file mode 100644 index 0000000..f667a0e --- /dev/null +++ b/frontend/src/entity/incomes/list/hooks/useDeleteIncomesList.ts @@ -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] }); + }, + }); +}; diff --git a/frontend/src/entity/incomes/list/hooks/useEditIncomesList.ts b/frontend/src/entity/incomes/list/hooks/useEditIncomesList.ts new file mode 100644 index 0000000..db7e8db --- /dev/null +++ b/frontend/src/entity/incomes/list/hooks/useEditIncomesList.ts @@ -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] }); + }, + }); +}; diff --git a/frontend/src/entity/incomes/list/hooks/useIncomeList.ts b/frontend/src/entity/incomes/list/hooks/useIncomeList.ts new file mode 100644 index 0000000..97195ce --- /dev/null +++ b/frontend/src/entity/incomes/list/hooks/useIncomeList.ts @@ -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], +}); diff --git a/frontend/src/entity/incomes/list/hooks/useIncomesList.ts b/frontend/src/entity/incomes/list/hooks/useIncomesList.ts new file mode 100644 index 0000000..4517a6b --- /dev/null +++ b/frontend/src/entity/incomes/list/hooks/useIncomesList.ts @@ -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], +}); diff --git a/frontend/src/entity/incomes/list/index.ts b/frontend/src/entity/incomes/list/index.ts new file mode 100644 index 0000000..33932ac --- /dev/null +++ b/frontend/src/entity/incomes/list/index.ts @@ -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'; diff --git a/frontend/src/entity/incomes/list/types.ts b/frontend/src/entity/incomes/list/types.ts new file mode 100644 index 0000000..1fabfc2 --- /dev/null +++ b/frontend/src/entity/incomes/list/types.ts @@ -0,0 +1,3 @@ +import type { paths } from '@/shared/api/schema'; + +export type IncomeList = paths['/incomes/list/{id}']['get']['responses']['200']['content']['application/json']; diff --git a/frontend/src/entity/incomes/list/ui/AddIncomeListForm/ui/AddIncomeListForm.module.css b/frontend/src/entity/incomes/list/ui/AddIncomeListForm/ui/AddIncomeListForm.module.css new file mode 100644 index 0000000..a97ffec --- /dev/null +++ b/frontend/src/entity/incomes/list/ui/AddIncomeListForm/ui/AddIncomeListForm.module.css @@ -0,0 +1,5 @@ +.form { + display: flex; + flex-direction: column; + gap: 10px; +} diff --git a/frontend/src/entity/incomes/list/ui/AddIncomeListForm/ui/AddIncomeListForm.test.tsx b/frontend/src/entity/incomes/list/ui/AddIncomeListForm/ui/AddIncomeListForm.test.tsx new file mode 100644 index 0000000..31dae73 --- /dev/null +++ b/frontend/src/entity/incomes/list/ui/AddIncomeListForm/ui/AddIncomeListForm.test.tsx @@ -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) => { + const onSubmit = jest.fn(); + + render(); + + 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: 'Название списка', + }); + }); +}); diff --git a/frontend/src/entity/incomes/list/ui/AddIncomeListForm/ui/AddIncomeListForm.ui.tsx b/frontend/src/entity/incomes/list/ui/AddIncomeListForm/ui/AddIncomeListForm.ui.tsx new file mode 100644 index 0000000..d9daaa7 --- /dev/null +++ b/frontend/src/entity/incomes/list/ui/AddIncomeListForm/ui/AddIncomeListForm.ui.tsx @@ -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, 'onSubmit'>; + +export type AddIncomeListFormProps = BaseFormProps & { + disabled?: boolean; + onSubmit: (data: AddIncomeListFormValues) => Promise | void; + ref?: RefObject; +}; + +const AddIncomeListForm: FC = (props) => { + const { + ref, + disabled, + onSubmit, + ...restProps + } = props; + + const onFormSubmitHandler: FormEventHandler = (event) => { + event.preventDefault(); + + const formData = new FormData(event.currentTarget); + + const title = formData.get('title'); + + if (typeof title === 'string') { + onSubmit({ + title, + }); + } + }; + + return ( +
+

Добавить список доходов

+ + + + +
+ ); +}; + +export default AddIncomeListForm; diff --git a/frontend/src/entity/incomes/list/ui/DeleteIncomeListForm/ui/DeleteIncomeListForm.test.tsx b/frontend/src/entity/incomes/list/ui/DeleteIncomeListForm/ui/DeleteIncomeListForm.test.tsx new file mode 100644 index 0000000..3e6c8ee --- /dev/null +++ b/frontend/src/entity/incomes/list/ui/DeleteIncomeListForm/ui/DeleteIncomeListForm.test.tsx @@ -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) => { + const onSubmit = jest.fn(); + + render( + , + ); + + 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); + }); +}); diff --git a/frontend/src/entity/incomes/list/ui/DeleteIncomeListForm/ui/DeleteIncomeListForm.ui.tsx b/frontend/src/entity/incomes/list/ui/DeleteIncomeListForm/ui/DeleteIncomeListForm.ui.tsx new file mode 100644 index 0000000..4be2577 --- /dev/null +++ b/frontend/src/entity/incomes/list/ui/DeleteIncomeListForm/ui/DeleteIncomeListForm.ui.tsx @@ -0,0 +1,71 @@ +import type { IncomeList } from '../../../types'; +import type { + FC, + FormEventHandler, + FormHTMLAttributes, + RefObject, +} from 'react'; + +type BaseFormProps = Omit, 'onSubmit'>; + +export type DeleteIncomeListFormProps = BaseFormProps & { + disabled?: boolean; + onSubmit?: (list: IncomeList) => Promise | void; + ref?: RefObject; + list: IncomeList; +}; + +const DeleteIncomeListForm: FC = (props) => { + const { + ref, + disabled, + onSubmit, + list, + ...restProps + } = props; + + const onFormSubmitHandler: FormEventHandler = (event) => { + event.preventDefault(); + onSubmit?.(list); + }; + + return ( +
+

Удалить список доходов

+ +

+ Вы действительно хотите удалить список доходов + {' '} + + + {`"${list.title}"`} + + + {' '} + ? +

+ + { + list.items && list.items.length > 0 + ? ( +

+ В нем содержится + {' '} + + + {list.items.length} + + + {' '} + доходов +

+ ) + : null + } + + +
+ ); +}; + +export default DeleteIncomeListForm; diff --git a/frontend/src/entity/incomes/list/ui/EditIncomeListForm/ui/EditIncomeListForm.module.css b/frontend/src/entity/incomes/list/ui/EditIncomeListForm/ui/EditIncomeListForm.module.css new file mode 100644 index 0000000..a97ffec --- /dev/null +++ b/frontend/src/entity/incomes/list/ui/EditIncomeListForm/ui/EditIncomeListForm.module.css @@ -0,0 +1,5 @@ +.form { + display: flex; + flex-direction: column; + gap: 10px; +} diff --git a/frontend/src/entity/incomes/list/ui/EditIncomeListForm/ui/EditIncomeListForm.test.tsx b/frontend/src/entity/incomes/list/ui/EditIncomeListForm/ui/EditIncomeListForm.test.tsx new file mode 100644 index 0000000..032e0a6 --- /dev/null +++ b/frontend/src/entity/incomes/list/ui/EditIncomeListForm/ui/EditIncomeListForm.test.tsx @@ -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) => { + const onSubmit = jest.fn(); + + render( + , + ); + + 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: 'Название списка', + }); + }); +}); diff --git a/frontend/src/entity/incomes/list/ui/EditIncomeListForm/ui/EditIncomeListForm.ui.tsx b/frontend/src/entity/incomes/list/ui/EditIncomeListForm/ui/EditIncomeListForm.ui.tsx new file mode 100644 index 0000000..27eb8ab --- /dev/null +++ b/frontend/src/entity/incomes/list/ui/EditIncomeListForm/ui/EditIncomeListForm.ui.tsx @@ -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, 'onSubmit'>; + +export type EditExpensesListFormProps = BaseFormProps & { + disabled?: boolean; + onSubmit: (data: EditIncomeListFormValues) => Promise | void; + ref?: RefObject; + list: IncomeList; +}; + +const EditExpensesListForm: FC = (props) => { + const { + ref, + disabled, + onSubmit, + list, + ...restProps + } = props; + + const onFormSubmitHandler: FormEventHandler = (event) => { + event.preventDefault(); + const formData = new FormData(event.currentTarget); + const title = formData.get('title'); + + if (typeof title === 'string') { + onSubmit({ + title, + }); + } + }; + + return ( +
+

Редактировать список доходов

+ + + + +
+ ); +}; + +export default EditExpensesListForm; diff --git a/frontend/src/features/incomes/items/AddIncomeItemButton/hooks/useAddIncomeItemButton.ts b/frontend/src/features/incomes/items/AddIncomeItemButton/hooks/useAddIncomeItemButton.ts new file mode 100644 index 0000000..4ca1727 --- /dev/null +++ b/frontend/src/features/incomes/items/AddIncomeItemButton/hooks/useAddIncomeItemButton.ts @@ -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['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 = (event) => { + setIsOpen(true); + onClick?.(event); + }; + + const onFormSubmitHandler = async (data: AddIncomeItemFormValues) => { + // todo добавить обработку ошибок (код + тест) + await addIncomeItemMutation(data); + + setIsOpen(false); + }; + + return { + isOpen, + onFormSubmitHandler, + onCloseModalHandler, + isPending, + onClickHandler, + }; +}; diff --git a/frontend/src/features/incomes/items/AddIncomeItemButton/index.ts b/frontend/src/features/incomes/items/AddIncomeItemButton/index.ts new file mode 100644 index 0000000..0efa2b5 --- /dev/null +++ b/frontend/src/features/incomes/items/AddIncomeItemButton/index.ts @@ -0,0 +1 @@ +export { default as AddIncomeItemButton } from './ui/AddIncomeItemButton.ui'; diff --git a/frontend/src/features/incomes/items/AddIncomeItemButton/ui/AddIncomeItemButton.test.tsx b/frontend/src/features/incomes/items/AddIncomeItemButton/ui/AddIncomeItemButton.test.tsx new file mode 100644 index 0000000..dfc9b35 --- /dev/null +++ b/frontend/src/features/incomes/items/AddIncomeItemButton/ui/AddIncomeItemButton.test.tsx @@ -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( + , + { 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(); + }); + }); +}); diff --git a/frontend/src/features/incomes/items/AddIncomeItemButton/ui/AddIncomeItemButton.ui.tsx b/frontend/src/features/incomes/items/AddIncomeItemButton/ui/AddIncomeItemButton.ui.tsx new file mode 100644 index 0000000..9b10711 --- /dev/null +++ b/frontend/src/features/incomes/items/AddIncomeItemButton/ui/AddIncomeItemButton.ui.tsx @@ -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; + +type AddIncomeItemButtonProps = BaseButtonProps & { + listId: IncomeList['id']; + ref?: RefObject; +}; + +const AddIncomeItemButton: FC = (props) => { + const { + listId, + ref, + onClick, + ...buttonProps + } = props; + + const { + isOpen, + isPending, + onClickHandler, + onCloseModalHandler, + onFormSubmitHandler, + } = useAddIncomeItemButton({ listId, onClick }); + + return ( + <> + + + + + + + + ); +}; + +export default AddIncomeItemButton; diff --git a/frontend/src/features/incomes/items/DeleteIncomeItemButton/hooks/useDeleteIncomeItemButton.ts b/frontend/src/features/incomes/items/DeleteIncomeItemButton/hooks/useDeleteIncomeItemButton.ts new file mode 100644 index 0000000..fae8267 --- /dev/null +++ b/frontend/src/features/incomes/items/DeleteIncomeItemButton/hooks/useDeleteIncomeItemButton.ts @@ -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; +}; + +export const useDeleteIncomeItemButton = (args: UseDeleteIncomeItemButtonArgs) => { + const { item, onClick } = args; + const [isOpen, setIsOpen] = useState(false); + + const onCloseModalHandler = () => { + setIsOpen(false); + }; + const onClickHandler: MouseEventHandler = (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, + }; +}; diff --git a/frontend/src/features/incomes/items/DeleteIncomeItemButton/index.ts b/frontend/src/features/incomes/items/DeleteIncomeItemButton/index.ts new file mode 100644 index 0000000..fa50443 --- /dev/null +++ b/frontend/src/features/incomes/items/DeleteIncomeItemButton/index.ts @@ -0,0 +1 @@ +export { default as DeleteIncomeItemButton } from './ui/DeleteIncomeItemButton.ui'; diff --git a/frontend/src/features/incomes/items/DeleteIncomeItemButton/ui/DeleteIncomeItemButton.test.tsx b/frontend/src/features/incomes/items/DeleteIncomeItemButton/ui/DeleteIncomeItemButton.test.tsx new file mode 100644 index 0000000..832aa17 --- /dev/null +++ b/frontend/src/features/incomes/items/DeleteIncomeItemButton/ui/DeleteIncomeItemButton.test.tsx @@ -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( + , + { 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(`/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(); + }); +}); diff --git a/frontend/src/features/incomes/items/DeleteIncomeItemButton/ui/DeleteIncomeItemButton.ui.tsx b/frontend/src/features/incomes/items/DeleteIncomeItemButton/ui/DeleteIncomeItemButton.ui.tsx new file mode 100644 index 0000000..ca5a5e1 --- /dev/null +++ b/frontend/src/features/incomes/items/DeleteIncomeItemButton/ui/DeleteIncomeItemButton.ui.tsx @@ -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; + +type DeleteIncomeItemButtonProps = BaseButtonProps & { + item: IncomeListItem; + ref?: RefObject; +}; +const DeleteIncomeItemButton: FC = (props) => { + const { + item, + ref, + onClick, + ...restProps + } = props; + const { + isOpen, + onCloseModalHandler, + onClickHandler, + isPending, + onFormSubmitHandler, + } = useDeleteIncomeItemButton({ item, onClick }); + + return ( + <> + + + + + + + ); +}; + +export default DeleteIncomeItemButton; diff --git a/frontend/src/features/incomes/items/EditIncomeItemButton/hooks/useIncomeItemButton.ts b/frontend/src/features/incomes/items/EditIncomeItemButton/hooks/useIncomeItemButton.ts new file mode 100644 index 0000000..f74ff85 --- /dev/null +++ b/frontend/src/features/incomes/items/EditIncomeItemButton/hooks/useIncomeItemButton.ts @@ -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; +}; + +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 = (event) => { + setIsOpen(true); + onClick?.(event); + }; + + const onFormSubmitHandler = async (data: EditIncomeItemFormValues) => { + // todo добавить обработку ошибок (код + тест) + await editIncomeItemMutation(data); + setIsOpen(false); + }; + + return { + isOpen, + onFormSubmitHandler, + onCloseModalHandler, + isPending, + onClickHandler, + }; +}; diff --git a/frontend/src/features/incomes/items/EditIncomeItemButton/index.ts b/frontend/src/features/incomes/items/EditIncomeItemButton/index.ts new file mode 100644 index 0000000..4f77181 --- /dev/null +++ b/frontend/src/features/incomes/items/EditIncomeItemButton/index.ts @@ -0,0 +1 @@ +export { default as EditIncomeItemButton } from './ui/EditIncomeItemButton.ui'; diff --git a/frontend/src/features/incomes/items/EditIncomeItemButton/ui/EditIncomeItemButton.test.tsx b/frontend/src/features/incomes/items/EditIncomeItemButton/ui/EditIncomeItemButton.test.tsx new file mode 100644 index 0000000..59a94de --- /dev/null +++ b/frontend/src/features/incomes/items/EditIncomeItemButton/ui/EditIncomeItemButton.test.tsx @@ -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( + , + { 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(); + }); + }); +}); diff --git a/frontend/src/features/incomes/items/EditIncomeItemButton/ui/EditIncomeItemButton.ui.tsx b/frontend/src/features/incomes/items/EditIncomeItemButton/ui/EditIncomeItemButton.ui.tsx new file mode 100644 index 0000000..470bf77 --- /dev/null +++ b/frontend/src/features/incomes/items/EditIncomeItemButton/ui/EditIncomeItemButton.ui.tsx @@ -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; + +type EditIncomeItemButtonProps = BaseButtonProps & { + item: IncomeListItem; + ref?: RefObject; +}; + +const EditIncomeItemButton: FC = (props) => { + const { + ref, + item, + onClick, + ...restProps + } = props; + + const { + isOpen, + onCloseModalHandler, + onClickHandler, + isPending, + onFormSubmitHandler, + } = useIncomeItemButton({ item, onClick }); + + return ( + <> + + + + + + + ); +}; + +export default EditIncomeItemButton; diff --git a/frontend/src/features/incomes/list/AddIncomeListButton/hooks/useAddIncomeListButton.ts b/frontend/src/features/incomes/list/AddIncomeListButton/hooks/useAddIncomeListButton.ts new file mode 100644 index 0000000..1845a56 --- /dev/null +++ b/frontend/src/features/incomes/list/AddIncomeListButton/hooks/useAddIncomeListButton.ts @@ -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; +}; + +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 = (event) => { + onClick?.(event); + setIsOpen(true); + }; + + const onFormSubmitHandler = async (data: AddExpenseListFormValues) => { + await addIncomeListMutation(data); + setIsOpen(false); + }; + + return { + isOpen, + isPending, + onCloseModalHandler, + onClickHandler, + onFormSubmitHandler, + }; +}; diff --git a/frontend/src/features/incomes/list/AddIncomeListButton/index.ts b/frontend/src/features/incomes/list/AddIncomeListButton/index.ts new file mode 100644 index 0000000..1f1552c --- /dev/null +++ b/frontend/src/features/incomes/list/AddIncomeListButton/index.ts @@ -0,0 +1 @@ +export { default as AddIncomeListButton } from './ui/AddIncomeListButton.ui'; diff --git a/frontend/src/features/incomes/list/AddIncomeListButton/ui/AddIncomeListButton.test.tsx b/frontend/src/features/incomes/list/AddIncomeListButton/ui/AddIncomeListButton.test.tsx new file mode 100644 index 0000000..12773dd --- /dev/null +++ b/frontend/src/features/incomes/list/AddIncomeListButton/ui/AddIncomeListButton.test.tsx @@ -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( + , + { 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( + '/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(); + }); + }); +}); diff --git a/frontend/src/features/incomes/list/AddIncomeListButton/ui/AddIncomeListButton.ui.tsx b/frontend/src/features/incomes/list/AddIncomeListButton/ui/AddIncomeListButton.ui.tsx new file mode 100644 index 0000000..abab855 --- /dev/null +++ b/frontend/src/features/incomes/list/AddIncomeListButton/ui/AddIncomeListButton.ui.tsx @@ -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 & { + ref?: Ref; +}; + +const AddIncomeListButton: FC = (props) => { + const { onClick, ...restProps } = props; + + const { + isOpen, + isPending, + onCloseModalHandler, + onClickHandler, + onFormSubmitHandler, + } = useAddIncomeListButton({ onClick }); + + return ( + <> + + + + + + + ); +}; + +export default AddIncomeListButton; diff --git a/frontend/src/features/incomes/list/DeleteIncomeListButton/hooks/useIncomeListButton.ts b/frontend/src/features/incomes/list/DeleteIncomeListButton/hooks/useIncomeListButton.ts new file mode 100644 index 0000000..d150ba2 --- /dev/null +++ b/frontend/src/features/incomes/list/DeleteIncomeListButton/hooks/useIncomeListButton.ts @@ -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, + }; +}; diff --git a/frontend/src/features/incomes/list/DeleteIncomeListButton/index.ts b/frontend/src/features/incomes/list/DeleteIncomeListButton/index.ts new file mode 100644 index 0000000..c8c444e --- /dev/null +++ b/frontend/src/features/incomes/list/DeleteIncomeListButton/index.ts @@ -0,0 +1 @@ +export { default as DeleteIncomeListButton } from './ui/DeleteIncomeListButton.ui'; diff --git a/frontend/src/features/incomes/list/DeleteIncomeListButton/ui/DeleteIncomeListButton.test.tsx b/frontend/src/features/incomes/list/DeleteIncomeListButton/ui/DeleteIncomeListButton.test.tsx new file mode 100644 index 0000000..baf00ba --- /dev/null +++ b/frontend/src/features/incomes/list/DeleteIncomeListButton/ui/DeleteIncomeListButton.test.tsx @@ -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( + , + { 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(`/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(); + }); +}); diff --git a/frontend/src/features/incomes/list/DeleteIncomeListButton/ui/DeleteIncomeListButton.ui.tsx b/frontend/src/features/incomes/list/DeleteIncomeListButton/ui/DeleteIncomeListButton.ui.tsx new file mode 100644 index 0000000..ec51c11 --- /dev/null +++ b/frontend/src/features/incomes/list/DeleteIncomeListButton/ui/DeleteIncomeListButton.ui.tsx @@ -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 & { + list: IncomeList; + ref?: Ref; +}; + +const DeleteIncomeListButton = (props: DeleteIncomeListButtonProps) => { + const { list, ...restProps } = props; + const { id } = list; + const { + isOpen, + isPending, + onCloseModalHandler, + onClickHandler, + onFormSubmitHandler, + } = useIncomeListButton({ id }); + + return ( + <> + + + + + + + ); +}; + +export default DeleteIncomeListButton; diff --git a/frontend/src/features/incomes/list/EditIncomeListButton/hooks/useIncomeListButton.ts b/frontend/src/features/incomes/list/EditIncomeListButton/hooks/useIncomeListButton.ts new file mode 100644 index 0000000..7cdfe9f --- /dev/null +++ b/frontend/src/features/incomes/list/EditIncomeListButton/hooks/useIncomeListButton.ts @@ -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, + }; +}; diff --git a/frontend/src/features/incomes/list/EditIncomeListButton/index.ts b/frontend/src/features/incomes/list/EditIncomeListButton/index.ts new file mode 100644 index 0000000..80c4794 --- /dev/null +++ b/frontend/src/features/incomes/list/EditIncomeListButton/index.ts @@ -0,0 +1 @@ +export { default as EditIncomeListButton } from './ui/EditIncomeListButton.ui'; diff --git a/frontend/src/features/incomes/list/EditIncomeListButton/ui/EditIncomeListButton.test.tsx b/frontend/src/features/incomes/list/EditIncomeListButton/ui/EditIncomeListButton.test.tsx new file mode 100644 index 0000000..4d47ffe --- /dev/null +++ b/frontend/src/features/incomes/list/EditIncomeListButton/ui/EditIncomeListButton.test.tsx @@ -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( + , + { 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(); + }); + }); +}); diff --git a/frontend/src/features/incomes/list/EditIncomeListButton/ui/EditIncomeListButton.ui.tsx b/frontend/src/features/incomes/list/EditIncomeListButton/ui/EditIncomeListButton.ui.tsx new file mode 100644 index 0000000..fa6f2fd --- /dev/null +++ b/frontend/src/features/incomes/list/EditIncomeListButton/ui/EditIncomeListButton.ui.tsx @@ -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 & { + list: IncomeList; + ref?: Ref; +}; + +const EditIncomeListButton: FC = (props) => { + const { list, ...restProps } = props; + + const { + isOpen, + isPending, + onCloseModalHandler, + onClickHandler, + onFormSubmitHandler, + } = useIncomeListButton({ id: list.id }); + + return ( + <> + + + + + + + ); +}; + +export default EditIncomeListButton; diff --git a/frontend/src/pages/Income/index.ts b/frontend/src/pages/Income/index.ts new file mode 100644 index 0000000..25e2c11 --- /dev/null +++ b/frontend/src/pages/Income/index.ts @@ -0,0 +1 @@ +export { default as IncomePage } from './ui/IncomePage.ui'; diff --git a/frontend/src/pages/Income/ui/IncomePage.ui.tsx b/frontend/src/pages/Income/ui/IncomePage.ui.tsx new file mode 100644 index 0000000..beef050 --- /dev/null +++ b/frontend/src/pages/Income/ui/IncomePage.ui.tsx @@ -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 ; + } + + if (incomeList?.items?.length && incomeList.items.length > 0) { + return ( + + ); + } + + return

Списков дохода нет

; + }; + + return ( + +
+

Списки расходов

+ + + + {renderContent()} + +
+
+ ); +}; + +export default IncomePage; diff --git a/frontend/src/pages/Incomes/index.ts b/frontend/src/pages/Incomes/index.ts new file mode 100644 index 0000000..d840dcc --- /dev/null +++ b/frontend/src/pages/Incomes/index.ts @@ -0,0 +1 @@ +export { default as IncomesPage } from './ui/IncomesPage.ui'; diff --git a/frontend/src/pages/Incomes/ui/IncomesPage.ui.tsx b/frontend/src/pages/Incomes/ui/IncomesPage.ui.tsx new file mode 100644 index 0000000..8e8ff44 --- /dev/null +++ b/frontend/src/pages/Incomes/ui/IncomesPage.ui.tsx @@ -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 ; + } + + if (incomesList && incomesList.length > 0) { + return ; + } + + return

Списков дохода нет

; + }; + + return ( + +
+

Списки дохода

+ +
+ +
+ + {renderContent()} + +
+
+ ); +}; + +export default IncomesPage; diff --git a/frontend/src/shared/api/queryKeys.ts b/frontend/src/shared/api/queryKeys.ts index 9df3012..8ced31c 100644 --- a/frontend/src/shared/api/queryKeys.ts +++ b/frontend/src/shared/api/queryKeys.ts @@ -4,5 +4,7 @@ export enum QueryKeys { BankCard = 'BankCard', ExpensesList = 'ExpensesList', ExpenseList = 'ExpenseList', + IncomesList = 'IncomesList', + IncomeList = 'IncomeList', Cash = 'Cash', } diff --git a/frontend/src/shared/api/schema.ts b/frontend/src/shared/api/schema.ts index 4d62710..343ccf9 100644 --- a/frontend/src/shared/api/schema.ts +++ b/frontend/src/shared/api/schema.ts @@ -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; 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; + }; + }; + }; } diff --git a/frontend/src/shared/router/index.ts b/frontend/src/shared/router/index.ts index 1fa5f4e..5aaddf4 100644 --- a/frontend/src/shared/router/index.ts +++ b/frontend/src/shared/router/index.ts @@ -1,2 +1,2 @@ export { PAGES } from './types'; -export type { ExpensePageParams } from './types'; +export type { ExpensePageParams, IncomePageParams } from './types'; diff --git a/frontend/src/shared/router/types.ts b/frontend/src/shared/router/types.ts index 37865ed..f2ed95d 100644 --- a/frontend/src/shared/router/types.ts +++ b/frontend/src/shared/router/types.ts @@ -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; +}; diff --git a/frontend/src/shared/utils/formatters/date.ts b/frontend/src/shared/utils/formatters/date.ts new file mode 100644 index 0000000..6efba12 --- /dev/null +++ b/frontend/src/shared/utils/formatters/date.ts @@ -0,0 +1,5 @@ +const dateFormater = new Intl.DateTimeFormat('ru-RU'); + +export function formatDate(date: Date): string { + return dateFormater.format(date); +} diff --git a/frontend/src/shared/utils/index.ts b/frontend/src/shared/utils/index.ts index 96a8720..b297a4b 100644 --- a/frontend/src/shared/utils/index.ts +++ b/frontend/src/shared/utils/index.ts @@ -3,3 +3,4 @@ export const wait = async (time: number) => new Promise((res) => { }); export { formatRub } from './formatters/currency'; +export { formatDate } from './formatters/date'; diff --git a/frontend/src/widgets/Sidebar/ui/SideBar.ui.tsx b/frontend/src/widgets/Sidebar/ui/SideBar.ui.tsx index 5f9ab6f..8528bdd 100644 --- a/frontend/src/widgets/Sidebar/ui/SideBar.ui.tsx +++ b/frontend/src/widgets/Sidebar/ui/SideBar.ui.tsx @@ -13,6 +13,12 @@ const SideBar = () => ( +
  • + + Доходы + +
  • +
  • Расходы diff --git a/frontend/src/widgets/incomes/IncomeItemsTable/index.ts b/frontend/src/widgets/incomes/IncomeItemsTable/index.ts new file mode 100644 index 0000000..76d7d5e --- /dev/null +++ b/frontend/src/widgets/incomes/IncomeItemsTable/index.ts @@ -0,0 +1 @@ +export { default as IncomeItemsTable } from './ui/IncomeItemsTable.ui'; diff --git a/frontend/src/widgets/incomes/IncomeItemsTable/ui/IncomeItemsTable.ui.tsx b/frontend/src/widgets/incomes/IncomeItemsTable/ui/IncomeItemsTable.ui.tsx new file mode 100644 index 0000000..ab77488 --- /dev/null +++ b/frontend/src/widgets/incomes/IncomeItemsTable/ui/IncomeItemsTable.ui.tsx @@ -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 = (props) => { + const { list } = props; + const { items } = list; + + return ( + + + + + + + + + + + + + + + + + + + + + {items?.map((item) => ( + + + + + + + + + + + + + + + + ))} + +
    ИдентификаторНазваниеОписаниеДатаСуммаВалютаДействия
    + {item.id} + + {item.title} + + {item.description} + + {formatDate(new Date(item.date))} + + {formatRub(item.amount)} + + {item.currency} + + + + +
    + ); +}; + +export default IncomeItemsTable; diff --git a/frontend/src/widgets/incomes/IncomeListTable/index.ts b/frontend/src/widgets/incomes/IncomeListTable/index.ts new file mode 100644 index 0000000..2a917e4 --- /dev/null +++ b/frontend/src/widgets/incomes/IncomeListTable/index.ts @@ -0,0 +1 @@ +export { default as IncomeListTable } from './ui/IncomeListTable.ui'; diff --git a/frontend/src/widgets/incomes/IncomeListTable/ui/IncomeListTable.module.css b/frontend/src/widgets/incomes/IncomeListTable/ui/IncomeListTable.module.css new file mode 100644 index 0000000..77af1d2 --- /dev/null +++ b/frontend/src/widgets/incomes/IncomeListTable/ui/IncomeListTable.module.css @@ -0,0 +1,3 @@ +.table, th, td { + border: 1px solid; +} diff --git a/frontend/src/widgets/incomes/IncomeListTable/ui/IncomeListTable.ui.tsx b/frontend/src/widgets/incomes/IncomeListTable/ui/IncomeListTable.ui.tsx new file mode 100644 index 0000000..a7db072 --- /dev/null +++ b/frontend/src/widgets/incomes/IncomeListTable/ui/IncomeListTable.ui.tsx @@ -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 ( + Loading... + ); + } + + return ( + + + + + + + + + + + + + + + + + {incomesList?.map((income) => ( + + + + + + + + + + + + ))} + +
    ПерейтиИдентификаторНазваниеКоличество элементовДействия
    + + Ссылка + + + {income.id} + + {income.title} + + {income.items?.length ?? 0} + + + + +
    + ); +}; + +export default IncomeListTable;