feat(frontend): add cash
This commit is contained in:
parent
e7c0a48ed1
commit
59ff36cb18
@ -1,44 +1,16 @@
|
|||||||
import './normalize.css';
|
import './normalize.css';
|
||||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
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 { 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 queryClient = new QueryClient();
|
||||||
|
|
||||||
const App = () => (
|
const App = () => (
|
||||||
<QueryClientProvider client={queryClient}>
|
<QueryClientProvider client={queryClient}>
|
||||||
<AuthContextProvider>
|
<AuthContextProvider>
|
||||||
<BrowserRouter>
|
<Routing />
|
||||||
<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>
|
|
||||||
</AuthContextProvider>
|
</AuthContextProvider>
|
||||||
</QueryClientProvider>
|
</QueryClientProvider>
|
||||||
);
|
);
|
||||||
|
|||||||
36
frontend/src/app/App/ui/Routing.tsx
Normal file
36
frontend/src/app/App/ui/Routing.tsx
Normal 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;
|
||||||
12
frontend/src/entity/cash/api/addCash.ts
Normal file
12
frontend/src/entity/cash/api/addCash.ts
Normal 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;
|
||||||
|
}
|
||||||
12
frontend/src/entity/cash/api/deleteCash.ts
Normal file
12
frontend/src/entity/cash/api/deleteCash.ts
Normal 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;
|
||||||
|
}
|
||||||
13
frontend/src/entity/cash/api/editCash.ts
Normal file
13
frontend/src/entity/cash/api/editCash.ts
Normal 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;
|
||||||
|
}
|
||||||
11
frontend/src/entity/cash/api/getCash.ts
Normal file
11
frontend/src/entity/cash/api/getCash.ts
Normal 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;
|
||||||
|
}
|
||||||
17
frontend/src/entity/cash/hooks/useAddCash.ts
Normal file
17
frontend/src/entity/cash/hooks/useAddCash.ts
Normal 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] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
10
frontend/src/entity/cash/hooks/useCash.ts
Normal file
10
frontend/src/entity/cash/hooks/useCash.ts
Normal 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],
|
||||||
|
});
|
||||||
17
frontend/src/entity/cash/hooks/useDeleteCash.ts
Normal file
17
frontend/src/entity/cash/hooks/useDeleteCash.ts
Normal 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] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
17
frontend/src/entity/cash/hooks/useEditCash.ts
Normal file
17
frontend/src/entity/cash/hooks/useEditCash.ts
Normal 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] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
16
frontend/src/entity/cash/index.ts
Normal file
16
frontend/src/entity/cash/index.ts
Normal 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';
|
||||||
4
frontend/src/entity/cash/types/index.ts
Normal file
4
frontend/src/entity/cash/types/index.ts
Normal 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'];
|
||||||
2
frontend/src/entity/cash/ui/AddCashForm/index.ts
Normal file
2
frontend/src/entity/cash/ui/AddCashForm/index.ts
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
export type { AddCashFormValues } from './ui/AddCashForm.ui';
|
||||||
|
export { default as AddCashForm } from './ui/AddCashForm.ui';
|
||||||
@ -0,0 +1,5 @@
|
|||||||
|
.form {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
@ -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,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -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;
|
||||||
1
frontend/src/entity/cash/ui/DeleteCashForm/index.ts
Normal file
1
frontend/src/entity/cash/ui/DeleteCashForm/index.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export { default as DeleteCashForm } from './ui/DeleteCashForm.ui';
|
||||||
@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -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;
|
||||||
2
frontend/src/entity/cash/ui/EditCashForm/index.ts
Normal file
2
frontend/src/entity/cash/ui/EditCashForm/index.ts
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
export type { EditCashFormValues } from './ui/EditCashForm.ui';
|
||||||
|
export { default as EditCashForm } from './ui/EditCashForm.ui';
|
||||||
@ -0,0 +1,5 @@
|
|||||||
|
.form {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
@ -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,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -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;
|
||||||
@ -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,
|
||||||
|
};
|
||||||
|
};
|
||||||
1
frontend/src/features/cash/AddCashButton/index.ts
Normal file
1
frontend/src/features/cash/AddCashButton/index.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export { default as AddCashButton } from './ui/AddCashButton.ui';
|
||||||
@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -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;
|
||||||
@ -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,
|
||||||
|
};
|
||||||
|
};
|
||||||
1
frontend/src/features/cash/DeleteCashButton/index.ts
Normal file
1
frontend/src/features/cash/DeleteCashButton/index.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export { default as DeleteCashButton } from './ui/DeleteCashButton.ui';
|
||||||
@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -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;
|
||||||
@ -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,
|
||||||
|
};
|
||||||
|
};
|
||||||
1
frontend/src/features/cash/EditCashButton/index.ts
Normal file
1
frontend/src/features/cash/EditCashButton/index.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export { default as EditCashButton } from './ui/EditCashButton.ui';
|
||||||
@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -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;
|
||||||
1
frontend/src/pages/Cash/index.ts
Normal file
1
frontend/src/pages/Cash/index.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export { default as CashPage } from './ui/CashPage.ui';
|
||||||
40
frontend/src/pages/Cash/ui/CashPage.ui.tsx
Normal file
40
frontend/src/pages/Cash/ui/CashPage.ui.tsx
Normal 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;
|
||||||
@ -4,4 +4,5 @@ export enum QueryKeys {
|
|||||||
BankCard = 'BankCard',
|
BankCard = 'BankCard',
|
||||||
ExpensesList = 'ExpensesList',
|
ExpensesList = 'ExpensesList',
|
||||||
ExpenseList = 'ExpenseList',
|
ExpenseList = 'ExpenseList',
|
||||||
|
Cash = 'Cash',
|
||||||
}
|
}
|
||||||
|
|||||||
@ -253,6 +253,58 @@ export interface paths {
|
|||||||
patch: operations["BankCardController_editBankCard"];
|
patch: operations["BankCardController_editBankCard"];
|
||||||
trace?: never;
|
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 type webhooks = Record<string, never>;
|
||||||
export interface components {
|
export interface components {
|
||||||
@ -530,6 +582,54 @@ export interface components {
|
|||||||
*/
|
*/
|
||||||
balance: number;
|
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;
|
responses: never;
|
||||||
parameters: 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;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@ -5,6 +5,7 @@ export enum PAGES {
|
|||||||
Expenses = '/expenses',
|
Expenses = '/expenses',
|
||||||
Expense = '/expenses/:id',
|
Expense = '/expenses/:id',
|
||||||
BankCards = '/bank-cards',
|
BankCards = '/bank-cards',
|
||||||
|
Cash = '/cash',
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ExpensePageParams = {
|
export type ExpensePageParams = {
|
||||||
|
|||||||
@ -24,6 +24,12 @@ const SideBar = () => (
|
|||||||
Карты
|
Карты
|
||||||
</NavLink>
|
</NavLink>
|
||||||
</li>
|
</li>
|
||||||
|
|
||||||
|
<li>
|
||||||
|
<NavLink to={PAGES.Cash}>
|
||||||
|
Наличные
|
||||||
|
</NavLink>
|
||||||
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
1
frontend/src/widgets/cash/CashTable/index.ts
Normal file
1
frontend/src/widgets/cash/CashTable/index.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export { default as CashTable } from './ui/CashTable.ui';
|
||||||
@ -0,0 +1,3 @@
|
|||||||
|
.table, th, td {
|
||||||
|
border: 1px solid;
|
||||||
|
}
|
||||||
60
frontend/src/widgets/cash/CashTable/ui/CashTable.ui.tsx
Normal file
60
frontend/src/widgets/cash/CashTable/ui/CashTable.ui.tsx
Normal 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;
|
||||||
Loading…
x
Reference in New Issue
Block a user