feat(frontend): add cash

This commit is contained in:
Sergey Krylov 2025-09-12 07:28:01 +03:00
parent e7c0a48ed1
commit 59ff36cb18
44 changed files with 1521 additions and 31 deletions

View File

@ -1,44 +1,16 @@
import './normalize.css';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import {
BrowserRouter,
Route,
Routes,
} 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';
import { AuthContextProvider } from '@/shared/context/AuthContext';
import { PAGES } from '@/shared/router';
import { AuthRoute } from '@/shared/ui/AuthRoute';
import Routing from './Routing';
const queryClient = new QueryClient();
const App = () => (
<QueryClientProvider client={queryClient}>
<AuthContextProvider>
<BrowserRouter>
<Routes>
<Route element={<AuthRoute />}>
<Route element={<HomePage />} path={PAGES.Home} />
<Route element={<ExpensesPage />} path={PAGES.Expenses} />
<Route element={<ExpensePage />} path={PAGES.Expense} />
<Route element={<BankCardsPage />} path={PAGES.BankCards} />
</Route>
<Route element={<AuthRoute needAuth={false} to="/" />}>
<Route element={<LoginPage />} path={PAGES.Login} />
<Route element={<RegisterPage />} path={PAGES.Register} />
</Route>
</Routes>
</BrowserRouter>
<Routing />
</AuthContextProvider>
</QueryClientProvider>
);

View File

@ -0,0 +1,36 @@
import { BrowserRouter, Route, Routes } from 'react-router';
import { LoginPage, RegisterPage } from '@/pages/Authorization';
import { BankCardsPage } from '@/pages/BankCard';
import { CashPage } from '@/pages/Cash';
import { ExpensePage } from '@/pages/Expense';
import { ExpensesPage } from '@/pages/Expenses';
import { HomePage } from '@/pages/Home';
import { PAGES } from '@/shared/router';
import { AuthRoute } from '@/shared/ui/AuthRoute';
const Routing = () => (
<BrowserRouter>
<Routes>
<Route element={<AuthRoute />}>
<Route element={<HomePage />} path={PAGES.Home} />
<Route element={<ExpensesPage />} path={PAGES.Expenses} />
<Route element={<ExpensePage />} path={PAGES.Expense} />
<Route element={<BankCardsPage />} path={PAGES.BankCards} />
<Route element={<CashPage />} path={PAGES.Cash} />
</Route>
<Route element={<AuthRoute needAuth={false} to="/" />}>
<Route element={<LoginPage />} path={PAGES.Login} />
<Route element={<RegisterPage />} path={PAGES.Register} />
</Route>
</Routes>
</BrowserRouter>
);
export default Routing;

View File

@ -0,0 +1,12 @@
import { api } from '@/shared/api/client';
import type { paths } from '@/shared/api/schema';
export type AddCashBody = paths['/cash']['post']['requestBody']['content']['application/json'];
export type AdCashResponse = paths['/cash']['post']['responses']['200']['content']['application/json'];
export async function addCash(data: AddCashBody) {
const { data: result } = await api.post<AdCashResponse>('/cash', data);
return result;
}

View File

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

View File

@ -0,0 +1,13 @@
import { api } from '@/shared/api/client';
import type { paths } from '@/shared/api/schema';
export type EditCashPath = paths['/cash/{id}']['patch']['parameters']['path'];
export type EditCashBody = paths['/cash/{id}']['patch']['requestBody']['content']['application/json'];
export type EditCashResponse = paths['/cash/{id}']['patch']['responses']['200']['content']['application/json'];
export async function editCash({ path, data }: { path: EditCashPath; data: EditCashBody }) {
const { data: result } = await api.patch<EditCashResponse>(`/cash/${path.id}`, data);
return result;
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,16 @@
export { useAddCash } from './hooks/useAddCash';
export { useCash } from './hooks/useCash';
export { useDeleteCash } from './hooks/useDeleteCash';
export { useEditCash } from './hooks/useEditCash';
export { AddCashForm } from './ui/AddCashForm';
export { DeleteCashForm } from './ui/DeleteCashForm';
export { EditCashForm } from './ui/EditCashForm';
export type { CashList, CashItem } from './types';
export type { AddCashFormValues } from './ui/AddCashForm';
export type { EditCashFormValues } from './ui/EditCashForm';
export type { AddCashBody, AdCashResponse } from './api/addCash';
export type { DeleteCashPath, DeleteCashResponse } from './api/deleteCash';
export type { EditCashBody, EditCashPath, EditCashResponse } from './api/editCash';

View File

@ -0,0 +1,4 @@
import type { paths } from '@/shared/api/schema';
export type CashList = paths['/cash']['get']['responses']['200']['content']['application/json'];
export type CashItem = paths['/cash/{id}']['get']['responses']['200']['content']['application/json'];

View File

@ -0,0 +1,2 @@
export type { AddCashFormValues } from './ui/AddCashForm.ui';
export { default as AddCashForm } from './ui/AddCashForm.ui';

View File

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

View File

@ -0,0 +1,64 @@
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import AddCashForm from './AddCashForm.ui';
import type { AddCashFormProps } from './AddCashForm.ui';
const renderForm = (props?: Partial<AddCashFormProps>) => {
const onSubmit = jest.fn();
render(<AddCashForm data-testid="add-cash-form" onSubmit={onSubmit} {...props} />);
const nameInputElem = screen.getByLabelText('Название');
const balanceInputElem = screen.getByLabelText('Баланс');
const submitButtonElem = screen.getByText('Добавить');
return {
nameInputElem,
balanceInputElem,
submitButtonElem,
onSubmit,
};
};
describe('Test AddCashForm', () => {
test('should render form', () => {
renderForm();
const form = screen.getByTestId('add-cash-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,
});
});
});

View File

@ -0,0 +1,79 @@
import { Input } from '@/shared/ui/Input';
import classes from './AddCashForm.module.css';
import type {
FC,
FormEventHandler,
FormHTMLAttributes,
RefObject,
} from 'react';
export type AddCashFormValues = {
name: string;
balance: number;
};
type BaseFormProps = Omit<FormHTMLAttributes<HTMLFormElement>, 'onSubmit'>;
export type AddCashFormProps = BaseFormProps & {
disabled?: boolean;
onSubmit?: (data: AddCashFormValues) => Promise<void> | void;
ref?: RefObject<HTMLFormElement>;
};
const AddCashForm: FC<AddCashFormProps> = (props) => {
const {
ref,
disabled,
onSubmit,
...formProps
} = props;
const onFormSubmitHandler: FormEventHandler<HTMLFormElement> = (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 (
<form
ref={ref}
className={classes.form}
onSubmit={onFormSubmitHandler}
{...formProps}
>
<h2>Добавить наличные</h2>
<Input
required
disabled={disabled}
label="Название"
name="name"
/>
<Input
defaultValue={0}
disabled={disabled}
label="Баланс"
name="balance"
step="0.01"
type="number"
/>
<button disabled={disabled} type="submit">Добавить</button>
</form>
);
};
export default AddCashForm;

View File

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

View File

@ -0,0 +1,55 @@
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import DeleteCashForm from './DeleteCashForm.ui';
import type { DeleteCashFormProps } from './DeleteCashForm.ui';
const deletingCash: DeleteCashFormProps['cash'] = {
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<DeleteCashFormProps>) => {
const onSubmit = jest.fn();
render(
<DeleteCashForm
cash={deletingCash}
data-testid="delete-cash-form"
onSubmit={onSubmit}
{...props}
/>,
);
const submitButtonElem = screen.getByText('Удалить');
return {
submitButtonElem,
onSubmit,
};
};
describe('Test DeleteCashForm', () => {
test('should render form', () => {
renderForm();
const form = screen.getByTestId('delete-cash-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(deletingCash);
});
});

View File

@ -0,0 +1,53 @@
import type { CashItem } from '../../../types';
import type {
FC,
FormEventHandler,
FormHTMLAttributes,
RefObject,
} from 'react';
type BaseFormProps = Omit<FormHTMLAttributes<HTMLFormElement>, 'onSubmit'>;
export type DeleteCashFormProps = BaseFormProps & {
cash: CashItem;
disabled?: boolean;
onSubmit?: (cash: CashItem) => Promise<void> | void;
ref?: RefObject<HTMLFormElement>;
};
const DeleteCashForm: FC<DeleteCashFormProps> = (props) => {
const {
cash,
disabled,
onSubmit,
ref,
...restProps
} = props;
const onFormSubmitHandler: FormEventHandler<HTMLFormElement> = (event) => {
event.preventDefault();
onSubmit?.(cash);
};
return (
<form ref={ref} onSubmit={onFormSubmitHandler} {...restProps}>
<h2>Удалить наличные</h2>
<p>
Вы действительно хотите удалить наличные
{' '}
<b>
{`"${cash.name}"`}
</b>
{' '}
?
</p>
<button disabled={disabled} type="submit">Удалить</button>
</form>
);
};
export default DeleteCashForm;

View File

@ -0,0 +1,2 @@
export type { EditCashFormValues } from './ui/EditCashForm.ui';
export { default as EditCashForm } from './ui/EditCashForm.ui';

View File

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

View File

@ -0,0 +1,91 @@
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import EditCashForm from './EditCashForm.ui';
import type { EditCashFormProps } from './EditCashForm.ui';
const editingCash = {
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<EditCashFormProps>) => {
const onSubmit = jest.fn();
render(
<EditCashForm
cash={editingCash}
data-testid="edit-bank-card-form"
onSubmit={onSubmit}
{...props}
/>,
);
const titleInputElem = screen.getByLabelText('Название');
const balanceInputElem = screen.getByLabelText('Баланс');
const submitButtonElem = screen.getByText('Сохранить');
return {
titleInputElem,
balanceInputElem,
submitButtonElem,
onSubmit,
};
};
describe('Test EditCashForm', () => {
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,
});
});
});

View File

@ -0,0 +1,83 @@
import { Input } from '@/shared/ui/Input';
import classes from './EditCashForm.module.css';
import type { CashItem } from '@/entity/cash';
import type {
FC,
FormEventHandler,
FormHTMLAttributes,
RefObject,
} from 'react';
type BaseFormProps = Omit<FormHTMLAttributes<HTMLFormElement>, 'onSubmit'>;
export type EditCashFormValues = {
name: string;
balance: number;
};
export type EditCashFormProps = BaseFormProps & {
cash: CashItem;
disabled?: boolean;
onSubmit?: (data: EditCashFormValues) => Promise<void> | void;
ref?: RefObject<HTMLFormElement>;
};
const EditCashForm: FC<EditCashFormProps> = (props) => {
const {
cash,
disabled,
onSubmit,
ref,
...restProps
} = props;
const onFormSubmitHandler: FormEventHandler<HTMLFormElement> = (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 (
<form
ref={ref}
className={classes.form}
onSubmit={onFormSubmitHandler}
{...restProps}
>
<h2>Редактировать наличные</h2>
<Input
required
defaultValue={cash.name}
disabled={disabled}
label="Название"
name="name"
/>
<Input
defaultValue={cash.balance}
disabled={disabled}
label="Баланс"
name="balance"
step="0.01"
type="number"
/>
<button disabled={disabled} type="submit">Сохранить</button>
</form>
);
};
export default EditCashForm;

View File

@ -0,0 +1,38 @@
import { type MouseEventHandler, useState } from 'react';
import { useAddCash } from '@/entity/cash';
import type { AddCashFormValues } from '@/entity/cash';
type UseAddCashButtonArgs = {
onClick?: MouseEventHandler<HTMLButtonElement>;
};
export const useAddCashButton = (args: UseAddCashButtonArgs) => {
const { onClick } = args;
const [isOpen, setIsOpen] = useState(false);
const { mutateAsync: addCashMutation, isPending } = useAddCash();
const onCloseModalHandler = () => {
setIsOpen(false);
};
const onClickHandler: MouseEventHandler<HTMLButtonElement> = (event) => {
onClick?.(event);
setIsOpen(true);
};
const onFormSubmitHandler = async (data: AddCashFormValues) => {
await addCashMutation(data);
setIsOpen(false);
};
return {
isOpen,
isPending,
onCloseModalHandler,
onClickHandler,
onFormSubmitHandler,
};
};

View File

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

View File

@ -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 AddCashButton from './AddCashButton.ui';
import type { AddCashBody, AdCashResponse } from '@/entity/cash';
const renderButton = () => {
render(
<AddCashButton data-testid="test-button" />,
{ wrapper: createReactQueryWrapper() },
);
const buttonElem = screen.getByTestId('test-button');
return {
buttonElem,
};
};
const openModal = async () => {
const { buttonElem } = renderButton();
await userEvent.click(buttonElem);
const modalElem = screen.getByRole('dialog');
return {
modalElem,
};
};
const fillForm = async (data: AddCashBody) => {
await userEvent.type(screen.getByLabelText('Название'), data.name);
await userEvent.type(screen.getByLabelText('Баланс'), String(data.balance));
};
const submitHandler = (requestSpy: jest.Mock) => http.post<never, AddCashBody, AdCashResponse>(
'/cash',
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 AddCashButton', () => {
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();
});
});
});

View File

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

View File

@ -0,0 +1,37 @@
import { useState } from 'react';
import { useDeleteCash } from '@/entity/cash';
import type { CashItem } from '@/entity/cash';
type UseDeleteCashButtonArgs = {
id: CashItem['id'];
};
export const useDeleteCashButton = (args: UseDeleteCashButtonArgs) => {
const { id } = args;
const [isOpen, setIsOpen] = useState(false);
const { mutateAsync: deleteCashAsync, isPending } = useDeleteCash();
const onClickHandler = () => {
setIsOpen(true);
};
const onCloseModalHandler = () => {
setIsOpen(false);
};
const onFormSubmitHandler = async () => {
await deleteCashAsync({ id });
setIsOpen(false);
};
return {
isOpen,
isPending,
onClickHandler,
onCloseModalHandler,
onFormSubmitHandler,
};
};

View File

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

View File

@ -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 DeleteCashButton from './DeleteCashButton.ui';
import type { CashItem } from '@/entity/cash';
import type { DeleteExpenseListPath } from '@/entity/expenses/list';
const deletingCash: CashItem = {
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-cash-button';
render(
<DeleteCashButton cash={deletingCash} data-testid={testId} />,
{ 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<DeleteExpenseListPath>(`/cash/${deletingCash.id}`, () => HttpResponse.json(deletingCash));
describe('Test DeleteCashButton', () => {
test('should correct render button', () => {
const { buttonElem } = renderButton();
expect(buttonElem).toBeInTheDocument();
expect(buttonElem).toBeVisible();
expect(buttonElem).toBeEnabled();
expect(buttonElem).toHaveTextContent('Удалить');
const modal = screen.queryByRole('dialog');
expect(modal).not.toBeInTheDocument();
});
test('should open modal after click', async () => {
const { modalElem } = await openModal();
expect(modalElem).toBeInTheDocument();
expect(modalElem).toBeVisible();
});
test('should close modal after submit', async () => {
server.use(submitHandler);
const { modalElem } = await openModal();
const submitButtonElem = within(modalElem).getByText('Удалить');
expect(submitButtonElem).toBeEnabled();
await userEvent.click(submitButtonElem);
expect(modalElem).not.toBeInTheDocument();
});
});

View File

@ -0,0 +1,47 @@
import { DeleteCashForm } from '@/entity/cash';
import { Modal } from '@/shared/ui/Modal';
import { useDeleteCashButton } from '../hooks/useDeleteCashButton';
import type { CashItem } from '@/entity/cash';
import type { ButtonHTMLAttributes, FC, Ref } from 'react';
export type DeleteCashButtonProps = ButtonHTMLAttributes<HTMLButtonElement> & {
cash: CashItem;
ref?: Ref<HTMLButtonElement>;
};
const DeleteCashButton: FC<DeleteCashButtonProps> = (props) => {
const {
cash,
ref,
onClick,
...restProps
} = props;
const {
isOpen,
isPending,
onCloseModalHandler,
onClickHandler,
onFormSubmitHandler,
} = useDeleteCashButton({ id: cash.id });
return (
<>
<button type="button" onClick={onClickHandler} {...restProps}>
Удалить
</button>
<Modal open={isOpen} onClose={onCloseModalHandler}>
<DeleteCashForm
cash={cash}
disabled={isPending}
onSubmit={onFormSubmitHandler}
/>
</Modal>
</>
);
};
export default DeleteCashButton;

View File

@ -0,0 +1,40 @@
import { useState } from 'react';
import { useEditCash } from '@/entity/cash';
import type { CashItem, EditCashFormValues } from '@/entity/cash';
type UseEditCashButtonArgs = {
cash: CashItem;
};
export const useEditCashButton = (args: UseEditCashButtonArgs) => {
const { cash } = args;
const [isOpen, setIsOpen] = useState(false);
const { mutateAsync: editCashAsync, isPending } = useEditCash();
const onClickHandler = () => {
setIsOpen(true);
};
const onCloseModalHandler = () => {
setIsOpen(false);
};
const onFormSubmitHandler = async (data: EditCashFormValues) => {
await editCashAsync({
data,
path: { id: cash.id },
});
setIsOpen(false);
};
return {
isOpen,
isPending,
onClickHandler,
onCloseModalHandler,
onFormSubmitHandler,
};
};

View File

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

View File

@ -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 EditCashButton from './EditCashButton.ui';
import type {
EditCashBody,
EditCashPath,
EditCashResponse,
CashItem,
} from '@/entity/cash';
const editingCash: CashItem = {
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: EditCashBody) => {
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-cash-button';
render(
<EditCashButton cash={editingCash} data-testid={testId} />,
{ 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<EditCashPath, EditCashBody, EditCashResponse>(
`/cash/${editingCash.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 EditCashButton', () => {
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();
});
});
});

View File

@ -0,0 +1,46 @@
import { EditCashForm } from '@/entity/cash';
import { Modal } from '@/shared/ui/Modal';
import { useEditCashButton } from '../hooks/useEditCashButton';
import type { CashItem } from '@/entity/cash';
import type { ButtonHTMLAttributes, FC, Ref } from 'react';
export type EditCashButtonProps = ButtonHTMLAttributes<HTMLButtonElement> & {
cash: CashItem;
ref?: Ref<HTMLButtonElement>;
};
const EditCashButton: FC<EditCashButtonProps> = (props) => {
const {
cash,
ref,
onClick,
...restProps
} = props;
const {
isOpen,
isPending,
onCloseModalHandler,
onClickHandler,
onFormSubmitHandler,
} = useEditCashButton({ cash });
return (
<>
<button type="button" onClick={onClickHandler} {...restProps}>
Редактировать
</button>
<Modal open={isOpen} onClose={onCloseModalHandler}>
<EditCashForm
cash={cash}
disabled={isPending}
onSubmit={onFormSubmitHandler}
/>
</Modal>
</>
);
};
export default EditCashButton;

View File

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

View File

@ -0,0 +1,40 @@
import { useCash } from '@/entity/cash';
import { AddCashButton } from '@/features/cash/AddCashButton';
import { Spinner } from '@/shared/ui/Spinner';
import { AppLayout } from '@/widgets/AppLayout';
import { CashTable } from '@/widgets/cash/CashTable';
import type { FC } from 'react';
const CashPage: FC = () => {
const { isPending, data: cashList } = useCash();
const renderContent = () => {
if (isPending) {
return <Spinner />;
}
if (cashList && cashList.length > 0) {
return <CashTable items={cashList} />;
}
return <p>Нет добавленных наличных</p>;
};
return (
<AppLayout>
<div>
<h1>Мои наличные</h1>
<div>
<AddCashButton />
</div>
{renderContent()}
</div>
</AppLayout>
);
};
export default CashPage;

View File

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

View File

@ -253,6 +253,58 @@ export interface paths {
patch: operations["BankCardController_editBankCard"];
trace?: never;
};
"/cash": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/**
* Найти все наличные
* @description Найти все наличные
*/
get: operations["CashController_getAllCashByUserId"];
put?: never;
/**
* Создать наличные
* @description Создать наличные
*/
post: operations["CashController_createCash"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/cash/{id}": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/**
* Найти наличные
* @description Найти наличные
*/
get: operations["CashController_getCashById"];
put?: never;
post?: never;
/**
* Удалить наличные
* @description Удалить наличные
*/
delete: operations["CashController_deleteCash"];
options?: never;
head?: never;
/**
* Редактировать наличные
* @description Редактировать наличные
*/
patch: operations["CashController_editCash"];
trace?: never;
};
}
export type webhooks = Record<string, never>;
export interface components {
@ -530,6 +582,54 @@ export interface components {
*/
balance: number;
};
CreateCashDto: {
/**
* @description Название
* @example Наличные
*/
name: string;
/**
* @description Баланс
* @example 10000
*/
balance: number;
};
CashDto: {
/** @description ID */
id: string;
/**
* @description Название
* @example Наличные
*/
name: string;
/**
* @description Баланс
* @example 10000
*/
balance: number;
/**
* Format: date-time
* @description Дата создания
*/
createdAt: string;
/**
* Format: date-time
* @description Дата обновления
*/
updatedAt: string;
};
EditCashDto: {
/**
* @description Название
* @example Наличные
*/
name: string;
/**
* @description Баланс
* @example 10000
*/
balance: number;
};
};
responses: never;
parameters: never;
@ -1097,4 +1197,142 @@ export interface operations {
};
};
};
CashController_getAllCashByUserId: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Успешно найдены наличные */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["CashDto"][];
};
};
};
};
CashController_createCash: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["CreateCashDto"];
};
};
responses: {
/** @description Успешно созданы наличные */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["CashDto"];
};
};
};
};
CashController_getCashById: {
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"]["CashDto"];
};
};
/** @description Кэш не найден */
404: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
CashController_deleteCash: {
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"]["CashDto"];
};
};
/** @description Кэш не найден */
404: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
CashController_editCash: {
parameters: {
query?: never;
header?: never;
path: {
/** @description ID наличных */
id: string;
};
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["EditCashDto"];
};
};
responses: {
/** @description Успешно отредактированы наличные */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["CashDto"];
};
};
/** @description Кэш не найден */
404: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
}

View File

@ -5,6 +5,7 @@ export enum PAGES {
Expenses = '/expenses',
Expense = '/expenses/:id',
BankCards = '/bank-cards',
Cash = '/cash',
}
export type ExpensePageParams = {

View File

@ -24,6 +24,12 @@ const SideBar = () => (
Карты
</NavLink>
</li>
<li>
<NavLink to={PAGES.Cash}>
Наличные
</NavLink>
</li>
</ul>
</div>
);

View File

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

View File

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

View File

@ -0,0 +1,60 @@
import { DeleteCashButton } from '@/features/cash/DeleteCashButton';
import { EditCashButton } from '@/features/cash/EditCashButton';
import { formatRub } from '@/shared/utils';
import classes from './CashTable.module.css';
import type { BankCards } from '@/entity/cards';
import type { FC } from 'react';
type CashTableProps = {
items: BankCards;
};
const CashTable: FC<CashTableProps> = (props) => {
const {
items,
} = props;
return (
<table className={classes.table}>
<thead>
<tr>
<th>Идентификатор</th>
<th>Название</th>
<th>Баланс</th>
<th>Действия</th>
</tr>
</thead>
<tbody>
{items.map((item) => (
<tr key={item.id}>
<td>
{item.id}
</td>
<td>
{item.name}
</td>
<td>
{formatRub(item.balance)}
</td>
<td>
<EditCashButton cash={item} />
<DeleteCashButton cash={item} />
</td>
</tr>
))}
</tbody>
</table>
);
};
export default CashTable;