diff --git a/frontend/src/app/App/ui/App.tsx b/frontend/src/app/App/ui/App.tsx index 81d4bef..6ab0f55 100644 --- a/frontend/src/app/App/ui/App.tsx +++ b/frontend/src/app/App/ui/App.tsx @@ -7,6 +7,7 @@ import { } from 'react-router'; import { LoginPage, RegisterPage } from '@/pages/Authorization'; +import { BankCardsPage } from '@/pages/BankCard'; import { ExpensePage } from '@/pages/Expense'; import { ExpensesPage } from '@/pages/Expenses'; import { HomePage } from '@/pages/Home'; @@ -27,6 +28,8 @@ const App = () => ( } path={PAGES.Expenses} /> } path={PAGES.Expense} /> + + } path={PAGES.BankCards} /> }> diff --git a/frontend/src/entity/cards/api/addBankCard.ts b/frontend/src/entity/cards/api/addBankCard.ts new file mode 100644 index 0000000..fe15a65 --- /dev/null +++ b/frontend/src/entity/cards/api/addBankCard.ts @@ -0,0 +1,12 @@ +import { api } from '@/shared/api/client'; + +import type { paths } from '@/shared/api/schema'; + +export type AddBankCardBody = paths['/bank-card']['post']['requestBody']['content']['application/json']; +export type AddBankCardResponse = paths['/bank-card']['post']['responses']['200']['content']['application/json']; + +export async function addBankCard(data: AddBankCardBody) { + const { data: result } = await api.post('/bank-card', data); + + return result; +} diff --git a/frontend/src/entity/cards/api/deleteBankCard.ts b/frontend/src/entity/cards/api/deleteBankCard.ts new file mode 100644 index 0000000..c9a4a87 --- /dev/null +++ b/frontend/src/entity/cards/api/deleteBankCard.ts @@ -0,0 +1,12 @@ +import { api } from '@/shared/api/client'; + +import type { paths } from '@/shared/api/schema'; + +export type DeleteBankCardPath = paths['/bank-card/{id}']['delete']['parameters']['path']; +export type DeleteBankCardResponse = paths['/bank-card/{id}']['delete']['responses']['200']['content']['application/json']; + +export async function deleteBankCard(path: DeleteBankCardPath) { + const { data: result } = await api.delete(`/bank-card/${path.id}`); + + return result; +} diff --git a/frontend/src/entity/cards/api/editBankCard.ts b/frontend/src/entity/cards/api/editBankCard.ts new file mode 100644 index 0000000..043701d --- /dev/null +++ b/frontend/src/entity/cards/api/editBankCard.ts @@ -0,0 +1,13 @@ +import { api } from '@/shared/api/client'; + +import type { paths } from '@/shared/api/schema'; + +export type EditBankCardPath = paths['/bank-card/{id}']['patch']['parameters']['path']; +export type EditBankCardBody = paths['/bank-card/{id}']['patch']['requestBody']['content']['application/json']; +export type EditBankCardResponse = paths['/bank-card/{id}']['patch']['responses']['200']['content']['application/json']; + +export async function editBankCard({ path, data }: { path: EditBankCardPath; data: EditBankCardBody }) { + const { data: result } = await api.patch(`/bank-card/${path.id}`, data); + + return result; +} diff --git a/frontend/src/entity/cards/api/getBankCards.ts b/frontend/src/entity/cards/api/getBankCards.ts new file mode 100644 index 0000000..87696d0 --- /dev/null +++ b/frontend/src/entity/cards/api/getBankCards.ts @@ -0,0 +1,11 @@ +import { api } from '@/shared/api/client'; + +import type { paths } from '@/shared/api/schema'; + +type BankCardsResponse = paths['/bank-card']['get']['responses']['200']['content']['application/json']; + +export async function getBankCards() { + const { data: result } = await api.get('/bank-card'); + + return result; +} diff --git a/frontend/src/entity/cards/hooks/useAddBankCard.ts b/frontend/src/entity/cards/hooks/useAddBankCard.ts new file mode 100644 index 0000000..9cc13cd --- /dev/null +++ b/frontend/src/entity/cards/hooks/useAddBankCard.ts @@ -0,0 +1,17 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query'; + +import { QueryKeys } from '@/shared/api/queryKeys'; + +import { addBankCard } from '../api/addBankCard'; + +export const useAddBankCard = () => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: addBankCard, + mutationKey: [QueryKeys.BankCards], + onSuccess: async () => { + await queryClient.invalidateQueries({ queryKey: [QueryKeys.BankCards] }); + }, + }); +}; diff --git a/frontend/src/entity/cards/hooks/useBankCards.ts b/frontend/src/entity/cards/hooks/useBankCards.ts new file mode 100644 index 0000000..e0880bf --- /dev/null +++ b/frontend/src/entity/cards/hooks/useBankCards.ts @@ -0,0 +1,10 @@ +import { useQuery } from '@tanstack/react-query'; + +import { QueryKeys } from '@/shared/api/queryKeys'; + +import { getBankCards } from '../api/getBankCards'; + +export const useBankCards = () => useQuery({ + queryFn: async () => getBankCards(), + queryKey: [QueryKeys.BankCards], +}); diff --git a/frontend/src/entity/cards/hooks/useDeleteBankCard.ts b/frontend/src/entity/cards/hooks/useDeleteBankCard.ts new file mode 100644 index 0000000..adfbc75 --- /dev/null +++ b/frontend/src/entity/cards/hooks/useDeleteBankCard.ts @@ -0,0 +1,17 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query'; + +import { QueryKeys } from '@/shared/api/queryKeys'; + +import { deleteBankCard } from '../api/deleteBankCard'; + +export const useDeleteBankCard = () => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: deleteBankCard, + mutationKey: [QueryKeys.BankCards], + onSuccess: async () => { + await queryClient.invalidateQueries({ queryKey: [QueryKeys.BankCards] }); + }, + }); +}; diff --git a/frontend/src/entity/cards/hooks/useEditBankCard.ts b/frontend/src/entity/cards/hooks/useEditBankCard.ts new file mode 100644 index 0000000..bdd6cf7 --- /dev/null +++ b/frontend/src/entity/cards/hooks/useEditBankCard.ts @@ -0,0 +1,17 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query'; + +import { QueryKeys } from '@/shared/api/queryKeys'; + +import { editBankCard } from '../api/editBankCard'; + +export const useEditBankCard = () => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: editBankCard, + mutationKey: [QueryKeys.BankCards], + onSuccess: async () => { + await queryClient.invalidateQueries({ queryKey: [QueryKeys.BankCards] }); + }, + }); +}; diff --git a/frontend/src/entity/cards/index.ts b/frontend/src/entity/cards/index.ts new file mode 100644 index 0000000..17c3778 --- /dev/null +++ b/frontend/src/entity/cards/index.ts @@ -0,0 +1,17 @@ +export { useBankCards } from './hooks/useBankCards'; +export { useAddBankCard } from './hooks/useAddBankCard'; +export { useDeleteBankCard } from './hooks/useDeleteBankCard'; +export { useEditBankCard } from './hooks/useEditBankCard'; + +export { AddBankCardForm } from './ui/AddBankCardForm'; +export { DeleteBankCardForm } from './ui/DeleteBankCardForm'; +export { EditBankCardForm } from './ui/EditBankCardForm'; + +export type { AddBankCardBody, AddBankCardResponse } from './api/addBankCard'; +export type { DeleteBankCardPath, DeleteBankCardResponse } from './api/deleteBankCard'; +export type { EditBankCardBody, EditBankCardPath, EditBankCardResponse } from './api/editBankCard'; + +export type { AddBankCardFormValues } from './ui/AddBankCardForm'; +export type { EditBankCardFormValues } from './ui/EditBankCardForm'; + +export type { BankCards, BankCard } from './types'; diff --git a/frontend/src/entity/cards/types/index.ts b/frontend/src/entity/cards/types/index.ts new file mode 100644 index 0000000..8534791 --- /dev/null +++ b/frontend/src/entity/cards/types/index.ts @@ -0,0 +1,4 @@ +import type { paths } from '@/shared/api/schema'; + +export type BankCards = paths['/bank-card']['get']['responses']['200']['content']['application/json']; +export type BankCard = paths['/bank-card/{id}']['get']['responses']['200']['content']['application/json']; diff --git a/frontend/src/entity/cards/ui/AddBankCardForm/index.ts b/frontend/src/entity/cards/ui/AddBankCardForm/index.ts new file mode 100644 index 0000000..043e107 --- /dev/null +++ b/frontend/src/entity/cards/ui/AddBankCardForm/index.ts @@ -0,0 +1,2 @@ +export type { AddBankCardFormValues } from './ui/AddBankCardForm.ui'; +export { default as AddBankCardForm } from './ui/AddBankCardForm.ui'; diff --git a/frontend/src/entity/cards/ui/AddBankCardForm/ui/AddBankCardForm.module.css b/frontend/src/entity/cards/ui/AddBankCardForm/ui/AddBankCardForm.module.css new file mode 100644 index 0000000..a97ffec --- /dev/null +++ b/frontend/src/entity/cards/ui/AddBankCardForm/ui/AddBankCardForm.module.css @@ -0,0 +1,5 @@ +.form { + display: flex; + flex-direction: column; + gap: 10px; +} diff --git a/frontend/src/entity/cards/ui/AddBankCardForm/ui/AddBankCardForm.test.tsx b/frontend/src/entity/cards/ui/AddBankCardForm/ui/AddBankCardForm.test.tsx new file mode 100644 index 0000000..18e4e6f --- /dev/null +++ b/frontend/src/entity/cards/ui/AddBankCardForm/ui/AddBankCardForm.test.tsx @@ -0,0 +1,64 @@ +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; + +import AddBankCardForm from './AddBankCardForm.ui'; + +import type { AddBankCardFormProps } from './AddBankCardForm.ui'; + +const renderForm = (props?: Partial) => { + const onSubmit = jest.fn(); + + render(); + + const nameInputElem = screen.getByLabelText('Название'); + const balanceInputElem = screen.getByLabelText('Баланс'); + const submitButtonElem = screen.getByText('Добавить'); + + return { + nameInputElem, + balanceInputElem, + submitButtonElem, + onSubmit, + }; +}; + +describe('Test AddBankCardForm', () => { + test('should render form', () => { + renderForm(); + const form = screen.getByTestId('add-balance-card-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 { + nameInputElem, + balanceInputElem, + submitButtonElem, + onSubmit, + } = renderForm(); + + await userEvent.type(nameInputElem, 'Тестовая карта'); + await userEvent.type(balanceInputElem, '500'); + + await userEvent.click(submitButtonElem); + + expect(onSubmit).toHaveBeenCalledTimes(1); + expect(onSubmit).toHaveBeenCalledWith({ + name: 'Тестовая карта', + balance: 500, + }); + }); +}); diff --git a/frontend/src/entity/cards/ui/AddBankCardForm/ui/AddBankCardForm.ui.tsx b/frontend/src/entity/cards/ui/AddBankCardForm/ui/AddBankCardForm.ui.tsx new file mode 100644 index 0000000..c81b957 --- /dev/null +++ b/frontend/src/entity/cards/ui/AddBankCardForm/ui/AddBankCardForm.ui.tsx @@ -0,0 +1,79 @@ +import { Input } from '@/shared/ui/Input'; + +import classes from './AddBankCardForm.module.css'; + +import type { + FC, + FormEventHandler, + FormHTMLAttributes, + RefObject, +} from 'react'; + +export type AddBankCardFormValues = { + name: string; + balance: number; +}; + +type BaseFormProps = Omit, 'onSubmit'>; + +export type AddBankCardFormProps = BaseFormProps & { + disabled?: boolean; + onSubmit?: (data: AddBankCardFormValues) => Promise | void; + ref?: RefObject; +}; + +const AddBankCardForm: FC = (props) => { + const { + ref, + disabled, + onSubmit, + ...formProps + } = props; + + const onFormSubmitHandler: FormEventHandler = (event) => { + event.preventDefault(); + + const formData = new FormData(event.currentTarget); + + const name = formData.get('name'); + const balance = formData.get('balance'); + + if (typeof name === 'string' && typeof balance === 'string') { + onSubmit?.({ + name, + balance: Number(balance), + }); + } + }; + + return ( +
+

Добавить карту

+ + + + + + +
+ ); +}; + +export default AddBankCardForm; diff --git a/frontend/src/entity/cards/ui/DeleteBankCardForm/index.ts b/frontend/src/entity/cards/ui/DeleteBankCardForm/index.ts new file mode 100644 index 0000000..dd79732 --- /dev/null +++ b/frontend/src/entity/cards/ui/DeleteBankCardForm/index.ts @@ -0,0 +1 @@ +export { default as DeleteBankCardForm } from './ui/DeleteBankCardForm.ui'; diff --git a/frontend/src/entity/cards/ui/DeleteBankCardForm/ui/DeleteBankCardForm.test.tsx b/frontend/src/entity/cards/ui/DeleteBankCardForm/ui/DeleteBankCardForm.test.tsx new file mode 100644 index 0000000..6953043 --- /dev/null +++ b/frontend/src/entity/cards/ui/DeleteBankCardForm/ui/DeleteBankCardForm.test.tsx @@ -0,0 +1,55 @@ +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; + +import DeleteBankCardForm from './DeleteBankCardForm.ui'; + +import type { DeleteBankCardFormProps } from './DeleteBankCardForm.ui'; + +const deletingBankCard: DeleteBankCardFormProps['bankCard'] = { + id: 'af6da4e1-f24f-4b43-8556-fa885626897d', + name: 'Тестовая карта', + balance: 1000, + 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 DeleteExpensesListForm', () => { + test('should render form', () => { + renderForm(); + const form = screen.getByTestId('delete-bank-card-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(deletingBankCard); + }); +}); diff --git a/frontend/src/entity/cards/ui/DeleteBankCardForm/ui/DeleteBankCardForm.ui.tsx b/frontend/src/entity/cards/ui/DeleteBankCardForm/ui/DeleteBankCardForm.ui.tsx new file mode 100644 index 0000000..9768147 --- /dev/null +++ b/frontend/src/entity/cards/ui/DeleteBankCardForm/ui/DeleteBankCardForm.ui.tsx @@ -0,0 +1,53 @@ +import type { BankCard } from '../../../types'; +import type { + FC, + FormEventHandler, + FormHTMLAttributes, + RefObject, +} from 'react'; + +type BaseFormProps = Omit, 'onSubmit'>; + +export type DeleteBankCardFormProps = BaseFormProps & { + bankCard: BankCard; + disabled?: boolean; + onSubmit?: (bankCard: BankCard) => Promise | void; + ref?: RefObject; +}; + +const DeleteBankCardForm: FC = (props) => { + const { + bankCard, + disabled, + onSubmit, + ref, + ...restProps + } = props; + + const onFormSubmitHandler: FormEventHandler = (event) => { + event.preventDefault(); + onSubmit?.(bankCard); + }; + + return ( +
+

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

+ +

+ Вы действительно хотите удалить банковскую карту + {' '} + + + {`"${bankCard.name}"`} + + + {' '} + ? +

+ + +
+ ); +}; + +export default DeleteBankCardForm; diff --git a/frontend/src/entity/cards/ui/EditBankCardForm/index.ts b/frontend/src/entity/cards/ui/EditBankCardForm/index.ts new file mode 100644 index 0000000..b12a42c --- /dev/null +++ b/frontend/src/entity/cards/ui/EditBankCardForm/index.ts @@ -0,0 +1,2 @@ +export type { EditBankCardFormValues } from './ui/EditBankCardForm.ui'; +export { default as EditBankCardForm } from './ui/EditBankCardForm.ui'; diff --git a/frontend/src/entity/cards/ui/EditBankCardForm/ui/EditBankCardForm.module.css b/frontend/src/entity/cards/ui/EditBankCardForm/ui/EditBankCardForm.module.css new file mode 100644 index 0000000..a97ffec --- /dev/null +++ b/frontend/src/entity/cards/ui/EditBankCardForm/ui/EditBankCardForm.module.css @@ -0,0 +1,5 @@ +.form { + display: flex; + flex-direction: column; + gap: 10px; +} diff --git a/frontend/src/entity/cards/ui/EditBankCardForm/ui/EditBankCardForm.test.tsx b/frontend/src/entity/cards/ui/EditBankCardForm/ui/EditBankCardForm.test.tsx new file mode 100644 index 0000000..6598a7e --- /dev/null +++ b/frontend/src/entity/cards/ui/EditBankCardForm/ui/EditBankCardForm.test.tsx @@ -0,0 +1,91 @@ +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; + +import EditBankCardForm from './EditBankCardForm.ui'; + +import type { EditBankCardFormProps } from './EditBankCardForm.ui'; + +const editingBankCard = { + id: 'af6da4e1-f24f-4b43-8556-fa885626897d', + name: 'Тестовая карта', + balance: 1000, + 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 balanceInputElem = screen.getByLabelText('Баланс'); + const submitButtonElem = screen.getByText('Сохранить'); + + return { + titleInputElem, + balanceInputElem, + submitButtonElem, + onSubmit, + }; +}; + +describe('Test EditBankCardForm', () => { + test('should render form', () => { + renderForm(); + const form = screen.getByTestId('edit-bank-card-form'); + expect(form).toBeInTheDocument(); + expect(form).toBeVisible(); + }); + + test('should inputs have default value', () => { + const { + titleInputElem, + balanceInputElem, + } = renderForm(); + expect(titleInputElem).toHaveValue('Тестовая карта'); + expect(balanceInputElem).toHaveValue(1000); + }); + + 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, + balanceInputElem, + submitButtonElem, + onSubmit, + } = renderForm(); + + await userEvent.clear(titleInputElem); + await userEvent.type(titleInputElem, 'Новое название карты'); + + await userEvent.clear(balanceInputElem); + await userEvent.type(balanceInputElem, '5000'); + + await userEvent.click(submitButtonElem); + + expect(onSubmit).toHaveBeenCalledTimes(1); + expect(onSubmit).toHaveBeenCalledWith({ + name: 'Новое название карты', + balance: 5000, + }); + }); +}); diff --git a/frontend/src/entity/cards/ui/EditBankCardForm/ui/EditBankCardForm.ui.tsx b/frontend/src/entity/cards/ui/EditBankCardForm/ui/EditBankCardForm.ui.tsx new file mode 100644 index 0000000..2f37a7b --- /dev/null +++ b/frontend/src/entity/cards/ui/EditBankCardForm/ui/EditBankCardForm.ui.tsx @@ -0,0 +1,83 @@ +import { Input } from '@/shared/ui/Input'; + +import classes from './EditBankCardForm.module.css'; + +import type { BankCard } from '@/entity/cards'; +import type { + FC, + FormEventHandler, + FormHTMLAttributes, + RefObject, +} from 'react'; + +type BaseFormProps = Omit, 'onSubmit'>; + +export type EditBankCardFormValues = { + name: string; + balance: number; +}; + +export type EditBankCardFormProps = BaseFormProps & { + bankCard: BankCard; + disabled?: boolean; + onSubmit?: (data: EditBankCardFormValues) => Promise | void; + ref?: RefObject; +}; + +const EditBankCardForm: FC = (props) => { + const { + bankCard, + disabled, + onSubmit, + ref, + ...restProps + } = props; + + const onFormSubmitHandler: FormEventHandler = (event) => { + event.preventDefault(); + + const formData = new FormData(event.currentTarget); + + const name = formData.get('name'); + const balance = formData.get('balance'); + + if (typeof name === 'string' && typeof balance === 'string') { + onSubmit?.({ + name, + balance: Number(balance), + }); + } + }; + + return ( +
+

Добавить карту

+ + + + + + +
+ ); +}; + +export default EditBankCardForm; diff --git a/frontend/src/entity/expenses/item/hooks/useAddExpenseItem.ts b/frontend/src/entity/expenses/item/hooks/useAddExpenseItem.ts index d2606fa..382e97f 100644 --- a/frontend/src/entity/expenses/item/hooks/useAddExpenseItem.ts +++ b/frontend/src/entity/expenses/item/hooks/useAddExpenseItem.ts @@ -1,5 +1,7 @@ import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { QueryKeys } from '@/shared/api/queryKeys'; + import { addExpenseItem, type AddExpenseItemBody, type AddExpenseItemPath } from '../api/addExpenseItem'; export const useAddExpenseItem = (path: AddExpenseItemPath) => { @@ -7,9 +9,9 @@ export const useAddExpenseItem = (path: AddExpenseItemPath) => { return useMutation({ mutationFn: async (data: AddExpenseItemBody) => addExpenseItem(path, data), - mutationKey: ['expenseList', path.id], + mutationKey: [QueryKeys.ExpenseList, path.id], onSuccess: async () => { - await queryClient.invalidateQueries({ queryKey: ['expenseList', path.id] }); + await queryClient.invalidateQueries({ queryKey: [QueryKeys.ExpenseList, path.id] }); }, }); }; diff --git a/frontend/src/entity/expenses/item/hooks/useDeleteExpenseItem.ts b/frontend/src/entity/expenses/item/hooks/useDeleteExpenseItem.ts index 4a1a082..492b12c 100644 --- a/frontend/src/entity/expenses/item/hooks/useDeleteExpenseItem.ts +++ b/frontend/src/entity/expenses/item/hooks/useDeleteExpenseItem.ts @@ -1,5 +1,7 @@ import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { QueryKeys } from '@/shared/api/queryKeys'; + import { deleteExpenseItem } from '../api/deleteExpenseItem'; import type { DeleteExpenseItemPath } from '../api/deleteExpenseItem'; @@ -9,9 +11,9 @@ export const useDeleteExpenseItem = (path: DeleteExpenseItemPath) => { return useMutation({ mutationFn: async () => deleteExpenseItem(path), - mutationKey: ['expenseList', path.listId], + mutationKey: [QueryKeys.ExpenseList, path.listId], onSuccess: async () => { - await queryClient.invalidateQueries({ queryKey: ['expenseList', path.listId] }); + await queryClient.invalidateQueries({ queryKey: [QueryKeys.ExpenseList, path.listId] }); }, }); }; diff --git a/frontend/src/entity/expenses/item/hooks/useEditExpenseItem.ts b/frontend/src/entity/expenses/item/hooks/useEditExpenseItem.ts index c7e58cd..eacb126 100644 --- a/frontend/src/entity/expenses/item/hooks/useEditExpenseItem.ts +++ b/frontend/src/entity/expenses/item/hooks/useEditExpenseItem.ts @@ -1,5 +1,7 @@ import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { QueryKeys } from '@/shared/api/queryKeys'; + import { editExpenseItem, type EditExpenseItemBody, type EditExpenseItemPath } from '../api/editExpenseItem'; export const useEditExpenseItem = (path: EditExpenseItemPath) => { @@ -7,9 +9,9 @@ export const useEditExpenseItem = (path: EditExpenseItemPath) => { return useMutation({ mutationFn: async (data: EditExpenseItemBody) => editExpenseItem(path, data), - mutationKey: ['expenseList', path.listId], + mutationKey: [QueryKeys.ExpenseList, path.listId], onSuccess: async () => { - await queryClient.invalidateQueries({ queryKey: ['expenseList', path.listId] }); + await queryClient.invalidateQueries({ queryKey: [QueryKeys.ExpenseList, path.listId] }); }, }); }; diff --git a/frontend/src/entity/expenses/item/ui/AddExpenseItemForm/AddExpenseItemForm.ui.tsx b/frontend/src/entity/expenses/item/ui/AddExpenseItemForm/AddExpenseItemForm.ui.tsx index f1611ee..72a07c0 100644 --- a/frontend/src/entity/expenses/item/ui/AddExpenseItemForm/AddExpenseItemForm.ui.tsx +++ b/frontend/src/entity/expenses/item/ui/AddExpenseItemForm/AddExpenseItemForm.ui.tsx @@ -92,6 +92,7 @@ const AddExpenseItemForm: FC = (props) => { disabled={disabled} label="Сумма" name="amount" + step="0.01" type="number" /> diff --git a/frontend/src/entity/expenses/item/ui/EditExpenseItemForm/EditExpenseItemForm.ui.tsx b/frontend/src/entity/expenses/item/ui/EditExpenseItemForm/EditExpenseItemForm.ui.tsx index 7971404..7aea2fb 100644 --- a/frontend/src/entity/expenses/item/ui/EditExpenseItemForm/EditExpenseItemForm.ui.tsx +++ b/frontend/src/entity/expenses/item/ui/EditExpenseItemForm/EditExpenseItemForm.ui.tsx @@ -95,6 +95,7 @@ const EditExpenseItemForm: FC = (props) => { disabled={disabled} label="Сумма" name="amount" + step="0.01" type="number" /> diff --git a/frontend/src/entity/expenses/list/hooks/useAddExpensesList.ts b/frontend/src/entity/expenses/list/hooks/useAddExpensesList.ts index 5e60b97..a51813f 100644 --- a/frontend/src/entity/expenses/list/hooks/useAddExpensesList.ts +++ b/frontend/src/entity/expenses/list/hooks/useAddExpensesList.ts @@ -1,5 +1,7 @@ import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { QueryKeys } from '@/shared/api/queryKeys'; + import { addExpensesList } from '../api/addExpensesList'; export const useAddExpensesList = () => { @@ -7,9 +9,9 @@ export const useAddExpensesList = () => { return useMutation({ mutationFn: addExpensesList, - mutationKey: ['expensesList'], + mutationKey: [QueryKeys.ExpenseList], onSuccess: async () => { - await queryClient.invalidateQueries({ queryKey: ['expensesList'] }); + await queryClient.invalidateQueries({ queryKey: [QueryKeys.ExpensesList] }); }, }); }; diff --git a/frontend/src/entity/expenses/list/hooks/useDeleteExpensesList.ts b/frontend/src/entity/expenses/list/hooks/useDeleteExpensesList.ts index ca031f4..c2dcb7f 100644 --- a/frontend/src/entity/expenses/list/hooks/useDeleteExpensesList.ts +++ b/frontend/src/entity/expenses/list/hooks/useDeleteExpensesList.ts @@ -1,5 +1,7 @@ import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { QueryKeys } from '@/shared/api/queryKeys'; + import { deleteExpensesList } from '../api/deleteExpensesList'; export const useDeleteExpensesList = () => { @@ -7,9 +9,9 @@ export const useDeleteExpensesList = () => { return useMutation({ mutationFn: deleteExpensesList, - mutationKey: ['expensesList'], + mutationKey: [QueryKeys.ExpenseList], onSuccess: async () => { - await queryClient.invalidateQueries({ queryKey: ['expensesList'] }); + await queryClient.invalidateQueries({ queryKey: [QueryKeys.ExpensesList] }); }, }); }; diff --git a/frontend/src/entity/expenses/list/hooks/useEditExpensesList.ts b/frontend/src/entity/expenses/list/hooks/useEditExpensesList.ts index cdfefe6..dd9c0f7 100644 --- a/frontend/src/entity/expenses/list/hooks/useEditExpensesList.ts +++ b/frontend/src/entity/expenses/list/hooks/useEditExpensesList.ts @@ -1,5 +1,7 @@ import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { QueryKeys } from '@/shared/api/queryKeys'; + import { editExpensesList } from '../api/editExpensesList'; export const useEditExpensesList = () => { @@ -7,9 +9,9 @@ export const useEditExpensesList = () => { return useMutation({ mutationFn: editExpensesList, - mutationKey: ['expensesList'], + mutationKey: [QueryKeys.ExpenseList], onSuccess: async () => { - await queryClient.invalidateQueries({ queryKey: ['expensesList'] }); + await queryClient.invalidateQueries({ queryKey: [QueryKeys.ExpensesList] }); }, }); }; diff --git a/frontend/src/entity/expenses/list/hooks/useExpenseList.ts b/frontend/src/entity/expenses/list/hooks/useExpenseList.ts index fff25ee..3f0bae3 100644 --- a/frontend/src/entity/expenses/list/hooks/useExpenseList.ts +++ b/frontend/src/entity/expenses/list/hooks/useExpenseList.ts @@ -1,10 +1,12 @@ import { useQuery } from '@tanstack/react-query'; +import { QueryKeys } from '@/shared/api/queryKeys'; + import { getExpenseList } from '../api/getExpenseList'; import type { ExpenseList } from '../types'; export const useExpenseList = (id: ExpenseList['id']) => useQuery({ queryFn: async () => getExpenseList({ id }), - queryKey: ['expenseList', id], + queryKey: [QueryKeys.ExpenseList, id], }); diff --git a/frontend/src/entity/expenses/list/hooks/useExpensesList.ts b/frontend/src/entity/expenses/list/hooks/useExpensesList.ts index de6fbbb..df3e789 100644 --- a/frontend/src/entity/expenses/list/hooks/useExpensesList.ts +++ b/frontend/src/entity/expenses/list/hooks/useExpensesList.ts @@ -1,8 +1,10 @@ import { useQuery } from '@tanstack/react-query'; +import { QueryKeys } from '@/shared/api/queryKeys'; + import { getExpenseLists } from '../api/getExpenseLists'; export const useExpensesList = () => useQuery({ queryFn: getExpenseLists, - queryKey: ['expensesList'], + queryKey: [QueryKeys.ExpensesList], }); diff --git a/frontend/src/features/bank-cards/AddBankCardButton/hooks/useAddBankCardButton.ts b/frontend/src/features/bank-cards/AddBankCardButton/hooks/useAddBankCardButton.ts new file mode 100644 index 0000000..0ed457b --- /dev/null +++ b/frontend/src/features/bank-cards/AddBankCardButton/hooks/useAddBankCardButton.ts @@ -0,0 +1,38 @@ +import { type MouseEventHandler, useState } from 'react'; + +import { useAddBankCard } from '@/entity/cards'; + +import type { AddBankCardFormValues } from '@/entity/cards'; + +type UseAddBankCardButtonArgs = { + onClick?: MouseEventHandler; +}; + +export const useAddBankCardButton = (args: UseAddBankCardButtonArgs) => { + const { onClick } = args; + const [isOpen, setIsOpen] = useState(false); + + const { mutateAsync: addBankCardMutation, isPending } = useAddBankCard(); + + const onCloseModalHandler = () => { + setIsOpen(false); + }; + + const onClickHandler: MouseEventHandler = (event) => { + onClick?.(event); + setIsOpen(true); + }; + + const onFormSubmitHandler = async (data: AddBankCardFormValues) => { + await addBankCardMutation(data); + setIsOpen(false); + }; + + return { + isOpen, + isPending, + onCloseModalHandler, + onClickHandler, + onFormSubmitHandler, + }; +}; diff --git a/frontend/src/features/bank-cards/AddBankCardButton/index.ts b/frontend/src/features/bank-cards/AddBankCardButton/index.ts new file mode 100644 index 0000000..d4a424f --- /dev/null +++ b/frontend/src/features/bank-cards/AddBankCardButton/index.ts @@ -0,0 +1 @@ +export { default as AddBankCardButton } from './ui/AddBankCardButton.ui'; diff --git a/frontend/src/features/bank-cards/AddBankCardButton/ui/AddBankCardButton.test.tsx b/frontend/src/features/bank-cards/AddBankCardButton/ui/AddBankCardButton.test.tsx new file mode 100644 index 0000000..50f2270 --- /dev/null +++ b/frontend/src/features/bank-cards/AddBankCardButton/ui/AddBankCardButton.test.tsx @@ -0,0 +1,107 @@ +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 AddBankCardButton from './AddBankCardButton.ui'; + +import type { AddBankCardBody, AddBankCardResponse } from '@/entity/cards'; + +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: AddBankCardBody) => { + await userEvent.type(screen.getByLabelText('Название'), data.name); + await userEvent.type(screen.getByLabelText('Баланс'), String(data.balance)); +}; + +const submitHandler = (requestSpy: jest.Mock) => http.post( + '/bank-card', + async ({ request }) => { + const body = await request.json(); + requestSpy(body); + + return HttpResponse.json({ + id: '10cc093c-3875-4d33-a40a-df526266e262', + name: 'Название карты', + balance: 999, + updatedAt: '2025-08-29T07:57:40.503Z', + createdAt: '2025-08-29T07:57:40.503Z', + }); + }, +); + +describe('Test AddBankCardButton', () => { + 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({ + balance: 999, + name: 'Тестовая карта', + }); + }); + + test('should send correct request', async () => { + await userEvent.click(submitButtonElem); + expect(requestSpy).toHaveBeenCalledTimes(1); + expect(requestSpy).toHaveBeenCalledWith({ + balance: 999, + name: 'Тестовая карта', + }); + }); + + test('should close modal after submit', async () => { + expect(submitButtonElem).toBeEnabled(); + await userEvent.click(submitButtonElem); + expect(modalElem).not.toBeInTheDocument(); + }); + }); +}); diff --git a/frontend/src/features/bank-cards/AddBankCardButton/ui/AddBankCardButton.ui.tsx b/frontend/src/features/bank-cards/AddBankCardButton/ui/AddBankCardButton.ui.tsx new file mode 100644 index 0000000..bfe91f8 --- /dev/null +++ b/frontend/src/features/bank-cards/AddBankCardButton/ui/AddBankCardButton.ui.tsx @@ -0,0 +1,41 @@ +import { AddBankCardForm } from '@/entity/cards'; +import { Modal } from '@/shared/ui/Modal'; + +import { useAddBankCardButton } from '../hooks/useAddBankCardButton'; + +import type { ButtonHTMLAttributes, FC, Ref } from 'react'; + +type AddBankCardButtonProps = ButtonHTMLAttributes & { + ref?: Ref; +}; + +const AddBankCardButton: FC = (props) => { + const { ref, onClick, ...restProps } = props; + + const { + isOpen, + isPending, + onCloseModalHandler, + onClickHandler, + onFormSubmitHandler, + } = useAddBankCardButton({ onClick }); + + return ( + <> + + + + + + + ); +}; + +export default AddBankCardButton; diff --git a/frontend/src/features/bank-cards/DeleteBankCardButton/hooks/useDeleteBankCardButton.ts b/frontend/src/features/bank-cards/DeleteBankCardButton/hooks/useDeleteBankCardButton.ts new file mode 100644 index 0000000..42aefee --- /dev/null +++ b/frontend/src/features/bank-cards/DeleteBankCardButton/hooks/useDeleteBankCardButton.ts @@ -0,0 +1,37 @@ +import { useState } from 'react'; + +import { useDeleteBankCard } from '@/entity/cards'; + +import type { BankCard } from '@/entity/cards'; + +type UseDeleteBankCardButtonArgs = { + id: BankCard['id']; +}; + +export const useDeleteBankCardButton = (args: UseDeleteBankCardButtonArgs) => { + const { id } = args; + const [isOpen, setIsOpen] = useState(false); + + const { mutateAsync: deleteBankCardAsync, isPending } = useDeleteBankCard(); + + const onClickHandler = () => { + setIsOpen(true); + }; + + const onCloseModalHandler = () => { + setIsOpen(false); + }; + + const onFormSubmitHandler = async () => { + await deleteBankCardAsync({ id }); + setIsOpen(false); + }; + + return { + isOpen, + isPending, + onClickHandler, + onCloseModalHandler, + onFormSubmitHandler, + }; +}; diff --git a/frontend/src/features/bank-cards/DeleteBankCardButton/index.ts b/frontend/src/features/bank-cards/DeleteBankCardButton/index.ts new file mode 100644 index 0000000..8beea38 --- /dev/null +++ b/frontend/src/features/bank-cards/DeleteBankCardButton/index.ts @@ -0,0 +1 @@ +export { default as DeleteBankCardButton } from './ui/DeleteBankCardButton.ui'; diff --git a/frontend/src/features/bank-cards/DeleteBankCardButton/ui/DeleteBankCardButton.test.tsx b/frontend/src/features/bank-cards/DeleteBankCardButton/ui/DeleteBankCardButton.test.tsx new file mode 100644 index 0000000..4d97d15 --- /dev/null +++ b/frontend/src/features/bank-cards/DeleteBankCardButton/ui/DeleteBankCardButton.test.tsx @@ -0,0 +1,76 @@ +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 DeleteBankCardButton from './DeleteBankCardButton.ui'; + +import type { BankCard } from '@/entity/cards'; +import type { DeleteExpenseListPath } from '@/entity/expenses/list'; + +const deletingBankCard: BankCard = { + id: 'af6da4e1-f24f-4b43-8556-fa885626897d', + name: 'Тестовая карта', + balance: 1000, + updatedAt: '2025-08-29T07:57:40.503Z', + createdAt: '2025-08-29T07:57:40.503Z', +}; + +const renderButton = () => { + const testId = 'delete-bank-card-button'; + render( + , + { wrapper: createReactQueryWrapper() }, + ); + + const buttonElem = screen.getByTestId(testId); + + return { + buttonElem, + }; +}; + +const openModal = async () => { + const { buttonElem } = renderButton(); + await userEvent.click(buttonElem); + const modalElem = screen.getByRole('dialog'); + + return { + modalElem, + }; +}; + +const submitHandler = http.delete(`/bank-card/${deletingBankCard.id}`, () => HttpResponse.json(deletingBankCard)); + +describe('Test DeleteBankCardButton', () => { + 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/bank-cards/DeleteBankCardButton/ui/DeleteBankCardButton.ui.tsx b/frontend/src/features/bank-cards/DeleteBankCardButton/ui/DeleteBankCardButton.ui.tsx new file mode 100644 index 0000000..cc66fe4 --- /dev/null +++ b/frontend/src/features/bank-cards/DeleteBankCardButton/ui/DeleteBankCardButton.ui.tsx @@ -0,0 +1,47 @@ +import { DeleteBankCardForm } from '@/entity/cards'; +import { Modal } from '@/shared/ui/Modal'; + +import { useDeleteBankCardButton } from '../hooks/useDeleteBankCardButton'; + +import type { BankCard } from '@/entity/cards'; +import type { ButtonHTMLAttributes, FC, Ref } from 'react'; + +export type DeleteBankCardButtonProps = ButtonHTMLAttributes & { + bankCard: BankCard; + ref?: Ref; +}; + +const DeleteBankCardButton: FC = (props) => { + const { + bankCard, + ref, + onClick, + ...restProps + } = props; + + const { + isOpen, + isPending, + onCloseModalHandler, + onClickHandler, + onFormSubmitHandler, + } = useDeleteBankCardButton({ id: bankCard.id }); + + return ( + <> + + + + + + + ); +}; + +export default DeleteBankCardButton; diff --git a/frontend/src/features/bank-cards/EditBankCardButton/hooks/useEditBankCardButton.ts b/frontend/src/features/bank-cards/EditBankCardButton/hooks/useEditBankCardButton.ts new file mode 100644 index 0000000..578b015 --- /dev/null +++ b/frontend/src/features/bank-cards/EditBankCardButton/hooks/useEditBankCardButton.ts @@ -0,0 +1,40 @@ +import { useState } from 'react'; + +import { useEditBankCard } from '@/entity/cards'; + +import type { BankCard, EditBankCardFormValues } from '@/entity/cards'; + +type UseDeleteBankCardButtonArgs = { + bankCard: BankCard; +}; + +export const useEditBankCardButton = (args: UseDeleteBankCardButtonArgs) => { + const { bankCard } = args; + const [isOpen, setIsOpen] = useState(false); + + const { mutateAsync: editBankCardAsync, isPending } = useEditBankCard(); + + const onClickHandler = () => { + setIsOpen(true); + }; + + const onCloseModalHandler = () => { + setIsOpen(false); + }; + + const onFormSubmitHandler = async (data: EditBankCardFormValues) => { + await editBankCardAsync({ + data, + path: { id: bankCard.id }, + }); + setIsOpen(false); + }; + + return { + isOpen, + isPending, + onClickHandler, + onCloseModalHandler, + onFormSubmitHandler, + }; +}; diff --git a/frontend/src/features/bank-cards/EditBankCardButton/index.ts b/frontend/src/features/bank-cards/EditBankCardButton/index.ts new file mode 100644 index 0000000..ca80714 --- /dev/null +++ b/frontend/src/features/bank-cards/EditBankCardButton/index.ts @@ -0,0 +1 @@ +export { default as EditBankCardButton } from './ui/EditBankCardButton.ui'; diff --git a/frontend/src/features/bank-cards/EditBankCardButton/ui/EditBankCardButton.test.tsx b/frontend/src/features/bank-cards/EditBankCardButton/ui/EditBankCardButton.test.tsx new file mode 100644 index 0000000..cfcd72b --- /dev/null +++ b/frontend/src/features/bank-cards/EditBankCardButton/ui/EditBankCardButton.test.tsx @@ -0,0 +1,127 @@ +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 EditBankCardButton from './EditBankCardButton.ui'; + +import type { + BankCard, + EditBankCardBody, + EditBankCardPath, + EditBankCardResponse, +} from '@/entity/cards'; + +const editingBankCard: BankCard = { + id: 'af6da4e1-f24f-4b43-8556-fa885626897d', + name: 'Тестовая карта', + balance: 1000, + updatedAt: '2025-08-29T07:57:40.503Z', + createdAt: '2025-08-29T07:57:40.503Z', +}; + +const fillForm = async (data: EditBankCardBody) => { + const nameInputElem = screen.getByLabelText('Название'); + const balanceInputElem = screen.getByLabelText('Баланс'); + + await userEvent.clear(nameInputElem); + await userEvent.type(nameInputElem, data.name); + + await userEvent.clear(balanceInputElem); + await userEvent.type(balanceInputElem, String(data.balance)); +}; + +const renderButton = () => { + const testId = 'edit-bank-card-button'; + render( + , + { wrapper: createReactQueryWrapper() }, + ); + + const buttonElem = screen.getByTestId(testId); + + 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( + `/bank-card/${editingBankCard.id}`, + async ({ request }) => { + const body = await request.json(); + requestSpy(body); + + return HttpResponse.json({ + id: 'af6da4e1-f24f-4b43-8556-fa885626897d', + name: 'Тестовая карта', + balance: 1000, + updatedAt: '2025-08-29T07:57:40.503Z', + createdAt: '2025-08-29T07:57:40.503Z', + }); + }, +); + +describe('Test EditBankCardButton', () => { + 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({ + balance: 999, + name: 'Тестовая карта', + }); + }); + + test('should send correct request', async () => { + await userEvent.click(submitButtonElem); + expect(requestSpy).toHaveBeenCalledTimes(1); + expect(requestSpy).toHaveBeenCalledWith({ + balance: 999, + name: 'Тестовая карта', + }); + }); + + test('should close modal after submit', async () => { + expect(submitButtonElem).toBeEnabled(); + await userEvent.click(submitButtonElem); + expect(modalElem).not.toBeInTheDocument(); + }); + }); +}); diff --git a/frontend/src/features/bank-cards/EditBankCardButton/ui/EditBankCardButton.ui.tsx b/frontend/src/features/bank-cards/EditBankCardButton/ui/EditBankCardButton.ui.tsx new file mode 100644 index 0000000..4154af8 --- /dev/null +++ b/frontend/src/features/bank-cards/EditBankCardButton/ui/EditBankCardButton.ui.tsx @@ -0,0 +1,46 @@ +import { EditBankCardForm } from '@/entity/cards'; +import { Modal } from '@/shared/ui/Modal'; + +import { useEditBankCardButton } from '../hooks/useEditBankCardButton'; + +import type { BankCard } from '@/entity/cards'; +import type { ButtonHTMLAttributes, FC, Ref } from 'react'; + +export type EditBankCardButtonProps = ButtonHTMLAttributes & { + bankCard: BankCard; + ref?: Ref; +}; +const EditBankCardButton: FC = (props) => { + const { + bankCard, + ref, + onClick, + ...restProps + } = props; + + const { + isOpen, + isPending, + onCloseModalHandler, + onClickHandler, + onFormSubmitHandler, + } = useEditBankCardButton({ bankCard }); + + return ( + <> + + + + + + + ); +}; + +export default EditBankCardButton; diff --git a/frontend/src/pages/BankCard/index.ts b/frontend/src/pages/BankCard/index.ts new file mode 100644 index 0000000..90092d8 --- /dev/null +++ b/frontend/src/pages/BankCard/index.ts @@ -0,0 +1 @@ +export { default as BankCardsPage } from './ui/BankCardsPage.ui'; diff --git a/frontend/src/pages/BankCard/ui/BankCardsPage.ui.tsx b/frontend/src/pages/BankCard/ui/BankCardsPage.ui.tsx new file mode 100644 index 0000000..64dd903 --- /dev/null +++ b/frontend/src/pages/BankCard/ui/BankCardsPage.ui.tsx @@ -0,0 +1,40 @@ +import { useBankCards } from '@/entity/cards'; +import { AddBankCardButton } from '@/features/bank-cards/AddBankCardButton'; +import { Spinner } from '@/shared/ui/Spinner'; +import { AppLayout } from '@/widgets/AppLayout'; +import { BankCardsTable } from '@/widgets/bank-cards/BankCardsTable'; + +import type { FC } from 'react'; + +const BankCardsPage: FC = () => { + const { isPending, data: bankCards } = useBankCards(); + + const renderContent = () => { + if (isPending) { + return ; + } + + if (bankCards && bankCards.length > 0) { + return ; + } + + return

Нет добавленных карт

; + }; + + return ( + +
+

Мои карты

+ +
+ +
+ + {renderContent()} + +
+
+ ); +}; + +export default BankCardsPage; diff --git a/frontend/src/shared/api/queryKeys.ts b/frontend/src/shared/api/queryKeys.ts new file mode 100644 index 0000000..7fc9572 --- /dev/null +++ b/frontend/src/shared/api/queryKeys.ts @@ -0,0 +1,7 @@ +// eslint-disable-next-line @typescript-eslint/naming-convention +export enum QueryKeys { + BankCards = 'BankCards', + BankCard = 'BankCard', + ExpensesList = 'ExpensesList', + ExpenseList = 'ExpenseList', +} diff --git a/frontend/src/shared/api/schema.ts b/frontend/src/shared/api/schema.ts index bc16bb6..a90424e 100644 --- a/frontend/src/shared/api/schema.ts +++ b/frontend/src/shared/api/schema.ts @@ -201,6 +201,58 @@ export interface paths { patch: operations["ExpensesController_editListItem"]; trace?: never; }; + "/bank-card": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Найти все карты + * @description Найти все банковсие карты + */ + get: operations["BankCardController_getBankCards"]; + put?: never; + /** + * Создать карту + * @description Создать банковскую карту + */ + post: operations["BankCardController_createBankCard"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/bank-card/{id}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Найти карту + * @description Найти банковскую карту + */ + get: operations["BankCardController_getBankCardById"]; + put?: never; + post?: never; + /** + * Удалить карту + * @description Удалить банковскую карту + */ + delete: operations["BankCardController_deleteBankCardById"]; + options?: never; + head?: never; + /** + * Редактировать карту + * @description Редактировать банковскую карту + */ + patch: operations["BankCardController_editBankCard"]; + trace?: never; + }; } export type webhooks = Record; export interface components { @@ -430,6 +482,54 @@ export interface components { */ count: number | null; }; + CreateBankCardDto: { + /** + * @description Название карты + * @example Карта Сбербанка + */ + name: string; + /** + * @description Баланс карты + * @example 100.95 + */ + balance: number; + }; + BankCardDto: { + /** @description ID */ + id: string; + /** + * @description Название карты + * @example Карта Сбербанка + */ + name: string; + /** + * @description Баланс карты + * @example 100.95 + */ + balance: number; + /** + * Format: date-time + * @description Дата создания + */ + createdAt: string; + /** + * Format: date-time + * @description Дата обновления + */ + updatedAt: string; + }; + EditBankCardDto: { + /** + * @description Название карта + * @example Карта Сбербанка + */ + name: string; + /** + * @description Баланс карты + * @example 100.95 + */ + balance: number; + }; }; responses: never; parameters: never; @@ -859,4 +959,142 @@ export interface operations { }; }; }; + BankCardController_getBankCards: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Успешно найдены банковские карты */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["BankCardDto"][]; + }; + }; + }; + }; + BankCardController_createBankCard: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["CreateBankCardDto"]; + }; + }; + responses: { + /** @description Успешно создана банковская карта */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["BankCardDto"]; + }; + }; + }; + }; + BankCardController_getBankCardById: { + 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"]["BankCardDto"]; + }; + }; + /** @description Карта не найдена */ + 404: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + BankCardController_deleteBankCardById: { + 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"]["BankCardDto"]; + }; + }; + /** @description Карта не найдена */ + 404: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + BankCardController_editBankCard: { + parameters: { + query?: never; + header?: never; + path: { + /** @description ID карты */ + id: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["EditBankCardDto"]; + }; + }; + responses: { + /** @description Успешно отредактирована банковская карта */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["BankCardDto"]; + }; + }; + /** @description Карта не найдена */ + 404: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; } diff --git a/frontend/src/shared/router/types.ts b/frontend/src/shared/router/types.ts index 3f18cf6..65e53dc 100644 --- a/frontend/src/shared/router/types.ts +++ b/frontend/src/shared/router/types.ts @@ -4,6 +4,7 @@ export enum PAGES { Register = '/register', Expenses = '/expenses', Expense = '/expenses/:id', + BankCards = '/bank-cards', } export type ExpensePageParams = { diff --git a/frontend/src/shared/utils/formatters/currency.ts b/frontend/src/shared/utils/formatters/currency.ts new file mode 100644 index 0000000..d6d6fee --- /dev/null +++ b/frontend/src/shared/utils/formatters/currency.ts @@ -0,0 +1,8 @@ +const rubFormatter = new Intl.NumberFormat('ru-RU', { + style: 'currency', + currency: 'RUB', +}); + +export function formatRub(value: number): string { + return rubFormatter.format(value); +} diff --git a/frontend/src/shared/utils/index.ts b/frontend/src/shared/utils/index.ts index 34cb8b8..96a8720 100644 --- a/frontend/src/shared/utils/index.ts +++ b/frontend/src/shared/utils/index.ts @@ -1,3 +1,5 @@ export const wait = async (time: number) => new Promise((res) => { setTimeout(res, time); }); + +export { formatRub } from './formatters/currency'; diff --git a/frontend/src/widgets/Sidebar/ui/SideBar.ui.tsx b/frontend/src/widgets/Sidebar/ui/SideBar.ui.tsx index 9b10731..b1c0c4e 100644 --- a/frontend/src/widgets/Sidebar/ui/SideBar.ui.tsx +++ b/frontend/src/widgets/Sidebar/ui/SideBar.ui.tsx @@ -18,6 +18,12 @@ const SideBar = () => ( Расходы + +
  • + + Карты + +
  • ); diff --git a/frontend/src/widgets/bank-cards/BankCardsTable/index.ts b/frontend/src/widgets/bank-cards/BankCardsTable/index.ts new file mode 100644 index 0000000..bf8f74e --- /dev/null +++ b/frontend/src/widgets/bank-cards/BankCardsTable/index.ts @@ -0,0 +1 @@ +export { default as BankCardsTable } from './ui/BankCardsTable.ui'; diff --git a/frontend/src/widgets/bank-cards/BankCardsTable/ui/BankCardsTable.module.css b/frontend/src/widgets/bank-cards/BankCardsTable/ui/BankCardsTable.module.css new file mode 100644 index 0000000..77af1d2 --- /dev/null +++ b/frontend/src/widgets/bank-cards/BankCardsTable/ui/BankCardsTable.module.css @@ -0,0 +1,3 @@ +.table, th, td { + border: 1px solid; +} diff --git a/frontend/src/widgets/bank-cards/BankCardsTable/ui/BankCardsTable.ui.tsx b/frontend/src/widgets/bank-cards/BankCardsTable/ui/BankCardsTable.ui.tsx new file mode 100644 index 0000000..da66766 --- /dev/null +++ b/frontend/src/widgets/bank-cards/BankCardsTable/ui/BankCardsTable.ui.tsx @@ -0,0 +1,60 @@ +import { DeleteBankCardButton } from '@/features/bank-cards/DeleteBankCardButton'; +import { EditBankCardButton } from '@/features/bank-cards/EditBankCardButton'; +import { formatRub } from '@/shared/utils'; + +import classes from './BankCardsTable.module.css'; + +import type { BankCards } from '@/entity/cards'; +import type { FC } from 'react'; + +type BankCardsTableProps = { + items: BankCards; +}; + +const BankCardsTable: FC = (props) => { + const { + items, + } = props; + + return ( + + + + + + + + + + + + + + + {items.map((item) => ( + + + + + + + + + + ))} + +
    ИдентификаторНазваниеБаланс картыДействия
    + {item.id} + + {item.name} + + {formatRub(item.balance)} + + + + +
    + ); +}; + +export default BankCardsTable;