feat(frontend): add payment type

This commit is contained in:
Sergey Krylov 2025-09-25 04:59:12 +03:00
parent 3fba931281
commit 6ed6cf8782
20 changed files with 464 additions and 40 deletions

View File

@ -0,0 +1,23 @@
export const bankCardList = [
{
balance: 0,
createdAt: '2025-09-09T01:58:06.993Z',
id: 'e2a7beee-2a1f-4881-97c5-0a7793c78adf',
name: 'Сбербанк',
updatedAt: '2025-09-09T01:58:06.993Z',
},
{
balance: 100,
createdAt: '2025-09-09T01:59:47.698Z',
id: '4360f93c-0e8e-4285-9b0d-ba5701f43013',
name: 'Тинькофф !',
updatedAt: '2025-09-11T02:33:10.293Z',
},
{
balance: 200,
createdAt: '2025-09-10T02:59:43.228Z',
id: 'b9f8eef6-5e8a-4e64-9e10-d0d6fabf05dc',
name: 'Альфа-Банк',
updatedAt: '2025-09-11T02:33:07.601Z',
},
];

View File

@ -0,0 +1,18 @@
export const cashList = [
{
balance: 41300,
createdAt: '2025-09-12T03:04:42.222Z',
id: '07d99f18-0618-444f-af5a-c5084d79bc31',
name: 'Аренда квартиры',
updatedAt: '2025-09-12T04:06:48.224Z',
userId: 'a94c46ad-64bb-4d6a-ba0f-85a466f57537',
},
{
balance: 19000,
createdAt: '2025-09-12T04:07:01.713Z',
id: 'f2d52442-c260-470c-9111-0da736f4daaf',
name: 'Свободные',
updatedAt: '2025-09-18T02:38:22.869Z',
userId: 'a94c46ad-64bb-4d6a-ba0f-85a466f57537',
},
];

View File

@ -8,6 +8,7 @@ export const items1 = [
description: 'Ежемесячная плата за интернет',
expenseListId: '1',
id: '1',
payment: null,
title: 'Счет за интернет',
updatedAt: '2024-04-01T12:00:00Z',
},
@ -20,6 +21,7 @@ export const items1 = [
description: 'Продукты на неделю',
expenseListId: '1',
id: '2',
payment: null,
title: 'Покупка продуктов',
updatedAt: '2024-04-02T12:00:00Z',
},
@ -39,6 +41,7 @@ export const items4 = [
description: null,
expenseListId: '4',
id: '3',
payment: null,
title: 'Бензин',
updatedAt: '2024-04-03T12:00:00Z',
},
@ -54,6 +57,7 @@ export const items5 = [
description: 'Покупка нового ноутбука',
expenseListId: '5',
id: '4',
payment: null,
title: 'Ноутбук',
updatedAt: '2024-04-04T12:00:00Z',
},
@ -66,6 +70,7 @@ export const items5 = [
description: 'Механическая мышка',
expenseListId: '5',
id: '5',
payment: null,
title: 'Мышка',
updatedAt: '2024-04-05T12:00:00Z',
},

View File

@ -0,0 +1,2 @@
export { bankCardList } from './bankCards/list';
export { cashList } from './cash/list';

View File

@ -0,0 +1,11 @@
import { http, HttpResponse } from 'msw';
import type { paths } from '@/shared/api/schema';
import { bankCardList } from '../../../data/bankCards/list';
type BankCardsResponse = paths['/bank-card']['get']['responses']['200']['content']['application/json'];
export const getBankCardList = http.get<never, never, BankCardsResponse>(
'/bank-card',
() => HttpResponse.json(bankCardList),
);

View File

@ -0,0 +1,11 @@
import { http, HttpResponse } from 'msw';
import type { paths } from '@/shared/api/schema';
import { cashList } from '../../../data/cash/list';
type CashResponse = paths['/cash']['get']['responses']['200']['content']['application/json'];
export const getCashList = http.get<never, never, CashResponse>(
'/cash',
() => HttpResponse.json(cashList),
);

View File

@ -1,3 +1,5 @@
export { loginHandler } from './auth/login/post';
export { getBankCardList } from './bank-cards/list/get';
export { getCashList } from './cash/list/get';
export { getExpenseItemListById } from './expense/list/:id/get';
export { getExpenseList } from './expense/list/get';

View File

@ -12,6 +12,7 @@ export default [
rules: {
'@stylistic/jsx-sort-props': 'off',
'@typescript-eslint/no-floating-promises': 'off',
'@typescript-eslint/sort-type-constituents': 'off',
'@typescript-eslint/strict-boolean-expressions': 'off',
'func-style': 'off',
'import/no-extraneous-dependencies': ['error', {

View File

@ -1,21 +1,128 @@
import { bankCardList, cashList } from '@mocks/browser/data';
import { getBankCardList, getCashList } from '@mocks/browser/handlers';
import { server } from '@mocks/jest/server';
import { createReactQueryWrapper } from '@mocks/jest/wrappers';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import type { BankCard } from '@/entity/cards';
import type { CashItem } from '@/entity/cash';
import type { AddExpenseItemFormProps } from './AddExpenseItemForm.ui';
import AddExpenseItemForm from './AddExpenseItemForm.ui';
const renderForm = (props?: Partial<AddExpenseItemFormProps>) => {
const onSubmit = jest.fn();
render(<AddExpenseItemForm data-testid="add-expense-item-form" onSubmit={onSubmit} {...props} />);
const getExpenseItemFormElements = () => {
const titleInputElem = screen.getByLabelText('Название');
const descriptionInputElem = screen.getByLabelText('Описание');
const dateInputElem = screen.getByLabelText('Дата');
const amountInputElem = screen.getByLabelText('Сумма');
const countInputElem = screen.getByLabelText('Количество');
const submitButtonElem = screen.getByText('Добавить');
const paymentTypeSelect = screen.getByLabelText('Способ оплаты');
const bankCardSelect = screen.queryByLabelText('Карта');
const cashSelect = screen.queryByLabelText('Наличные');
return {
amountInputElem,
bankCardSelect,
cashSelect,
countInputElem,
dateInputElem,
descriptionInputElem,
paymentTypeSelect,
submitButtonElem,
titleInputElem,
};
};
type ExpenseFormValues = {
amount?: string;
count?: string;
date?: string;
description?: string;
// eslint-disable-next-line @typescript-eslint/no-duplicate-type-constituents
paymentId?: BankCard['id'] | CashItem['id'];
paymentType?: 'BANK_CARD' | 'CASH' | 'none';
title?: string;
};
// eslint-disable-next-line jest/no-export
export const fillAddExpenseItemForm = async (data: ExpenseFormValues) => {
const {
amount,
count,
date,
description,
paymentId,
paymentType = 'none',
title,
} = data;
const {
amountInputElem,
// bankCardSelect,
// cashSelect,
countInputElem,
dateInputElem,
descriptionInputElem,
paymentTypeSelect,
titleInputElem,
} = getExpenseItemFormElements();
if (title) {
await userEvent.clear(titleInputElem);
await userEvent.type(titleInputElem, title);
}
if (description) {
await userEvent.clear(descriptionInputElem);
await userEvent.type(descriptionInputElem, description);
}
if (date) {
await userEvent.clear(dateInputElem);
await userEvent.type(dateInputElem, date);
}
if (amount) {
await userEvent.clear(amountInputElem);
await userEvent.type(amountInputElem, amount);
}
if (count) {
await userEvent.clear(countInputElem);
await userEvent.type(countInputElem, count);
}
if (paymentType !== 'none') {
await userEvent.selectOptions(paymentTypeSelect, paymentType);
const { bankCardSelect, cashSelect } = getExpenseItemFormElements();
if (paymentType === 'BANK_CARD' && bankCardSelect && paymentId) {
await userEvent.selectOptions(bankCardSelect, paymentId);
} else if (paymentType === 'CASH' && cashSelect && paymentId) {
await userEvent.selectOptions(cashSelect, paymentId);
}
}
};
const renderForm = (props?: Partial<AddExpenseItemFormProps>) => {
server.use(getBankCardList, getCashList);
const onSubmit = jest.fn();
render(
<AddExpenseItemForm data-testid="add-expense-item-form" onSubmit={onSubmit} {...props} />,
{ wrapper: createReactQueryWrapper() },
);
const {
amountInputElem,
countInputElem,
dateInputElem,
descriptionInputElem,
submitButtonElem,
titleInputElem,
} = getExpenseItemFormElements();
return {
amountInputElem,
@ -48,22 +155,52 @@ describe('Test AddExpenseItemForm', () => {
Object.values(formElems).forEach(assertion);
});
test('should call onSubmit with values', async () => {
describe('should call onSubmit with values', () => {
const setup = async () => {
const {
amountInputElem,
countInputElem,
dateInputElem,
descriptionInputElem,
onSubmit,
submitButtonElem,
titleInputElem,
} = renderForm();
await userEvent.type(titleInputElem, 'Название элемента');
await userEvent.type(descriptionInputElem, 'Описание элемента');
await userEvent.type(dateInputElem, '2025-08-29');
await userEvent.type(amountInputElem, '12');
await userEvent.type(countInputElem, '1');
await fillAddExpenseItemForm({
amount: '12',
count: '1',
date: '2025-08-29',
description: 'Описание элемента',
title: 'Название элемента',
});
return {
onSubmit,
submitButtonElem,
};
};
test('no payment', async () => {
const { onSubmit, submitButtonElem } = await setup();
await userEvent.click(submitButtonElem);
expect(onSubmit).toHaveBeenCalledTimes(1);
expect(onSubmit).toHaveBeenCalledWith({
amount: 12,
count: 1,
currency: 'RUB',
date: '2025-08-29T00:00:00.000Z',
description: 'Описание элемента',
payment: null,
title: 'Название элемента',
});
});
test('bank card payment', async () => {
const { onSubmit, submitButtonElem } = await setup();
await fillAddExpenseItemForm({ paymentType: 'BANK_CARD' });
const { bankCardSelect } = getExpenseItemFormElements();
expect(bankCardSelect).toBeVisible();
expect(bankCardSelect).toBeEnabled();
expect(bankCardSelect).toHaveValue(bankCardList[0].id);
await fillAddExpenseItemForm({ paymentId: bankCardList[1].id, paymentType: 'BANK_CARD' });
await userEvent.click(submitButtonElem);
@ -74,7 +211,40 @@ describe('Test AddExpenseItemForm', () => {
currency: 'RUB',
date: '2025-08-29T00:00:00.000Z',
description: 'Описание элемента',
payment: {
id: bankCardList[1].id,
type: 'BANK_CARD',
},
title: 'Название элемента',
});
});
test('cash payment', async () => {
const { onSubmit, submitButtonElem } = await setup();
await fillAddExpenseItemForm({ paymentType: 'CASH' });
const { cashSelect } = getExpenseItemFormElements();
expect(cashSelect).toBeVisible();
expect(cashSelect).toBeEnabled();
expect(cashSelect).toHaveValue(cashList[0].id);
await fillAddExpenseItemForm({ paymentId: cashList[1].id, paymentType: 'CASH' });
await userEvent.click(submitButtonElem);
expect(onSubmit).toHaveBeenCalledTimes(1);
expect(onSubmit).toHaveBeenCalledWith({
amount: 12,
count: 1,
currency: 'RUB',
date: '2025-08-29T00:00:00.000Z',
description: 'Описание элемента',
payment: {
id: cashList[1].id,
type: 'CASH',
},
title: 'Название элемента',
});
});
});
});

View File

@ -1,11 +1,23 @@
import type {
ChangeEventHandler,
FC,
FormEventHandler,
FormHTMLAttributes,
RefObject,
} from 'react';
import {
useMemo,
useState,
} from 'react';
import type { ExpenseListItem } from '@/entity/expenses/item';
import { useBankCards } from '@/entity/cards';
import { useCash } from '@/entity/cash';
import { Input } from '@/shared/ui/Input';
import { Select } from '@/shared/ui/Select';
import { formatRub } from '@/shared/utils';
import classes from './AddExpenseItemForm.module.css';
@ -21,6 +33,10 @@ export type AddExpenseItemFormValues = {
currency: string;
date: string;
description: string;
payment: null | {
id: string;
type: 'BANK_CARD' | 'CASH';
};
title: string;
};
@ -34,9 +50,10 @@ const AddExpenseItemForm: FC<AddExpenseItemFormProps> = (props) => {
...formProps
} = props;
const [paymentType, setPaymentType] = useState<NonNullable<ExpenseListItem['payment']>['type']>(null);
const onFormSubmitHandler: FormEventHandler<HTMLFormElement> = (event) => {
event.preventDefault();
const formData = new FormData(event.currentTarget);
const title = formData.get('title');
@ -44,6 +61,28 @@ const AddExpenseItemForm: FC<AddExpenseItemFormProps> = (props) => {
const amount = formData.get('amount');
const description = formData.get('description');
const count = formData.get('count');
const paymentMethod = formData.get('paymentMethod');
const bankCardId = formData.get('bank-card');
const cashId = formData.get('cash');
const getPayment = () => {
switch (paymentMethod) {
case 'BANK_CARD':
return {
id: bankCardId,
type: 'BANK_CARD',
};
case 'CASH':
return {
id: cashId,
type: 'CASH',
};
case 'none':
case null:
default:
return null;
}
};
if (typeof title === 'string' && typeof date === 'string' && typeof amount === 'string' && typeof description === 'string' && typeof count === 'string') {
onSubmit?.({
@ -52,10 +91,63 @@ const AddExpenseItemForm: FC<AddExpenseItemFormProps> = (props) => {
currency: 'RUB',
date: new Date(date).toISOString(),
description,
// @ts-ignore todo
payment: getPayment(),
title,
});
}
};
const { data: bankCards } = useBankCards();
const { data: cash } = useCash();
const bankCardOptions = bankCards?.map((card) => ({
label: `${card.name} (${formatRub(card.balance)})`,
value: card.id,
}));
const cashOptions = cash?.map((cashItem) => ({
label: `${cashItem.name} (${formatRub(cashItem.balance)})`,
value: cashItem.id,
}));
const paymentOptions = useMemo(() => {
const options = [{ label: 'Без оплаты', value: 'none' }];
if (bankCardOptions && bankCardOptions.length > 0) {
options.push({ label: 'Банковская карта', value: 'BANK_CARD' });
}
if (cashOptions && cashOptions.length > 0) {
options.push({ label: 'Наличные', value: 'CASH' });
}
return options;
}, [bankCardOptions, cashOptions]);
const onChangePaymentHandler: ChangeEventHandler<HTMLSelectElement> = (event) => {
const { value } = event.target;
if (value === 'none') {
setPaymentType(null);
} else {
// @ts-ignore todo
setPaymentType(value);
}
};
const renderCardSelect = () => (
<Select
label="Карта"
name="bank-card"
options={bankCardOptions ?? []}
/>
);
const renderCashSelect = () => (
<Select
label="Наличные"
name="cash"
options={cashOptions ?? []}
/>
);
return (
<form
@ -80,6 +172,7 @@ const AddExpenseItemForm: FC<AddExpenseItemFormProps> = (props) => {
/>
<Input
defaultValue={new Date().toISOString().split('T')[0]}
disabled={disabled}
label="Дата"
name="date"
@ -97,12 +190,24 @@ const AddExpenseItemForm: FC<AddExpenseItemFormProps> = (props) => {
/>
<Input
defaultValue={1}
disabled={disabled}
label="Количество"
name="count"
type="number"
/>
<Select
label="Способ оплаты"
name="paymentMethod"
onChange={onChangePaymentHandler}
options={paymentOptions}
/>
{paymentType === 'BANK_CARD' && renderCardSelect()}
{paymentType === 'CASH' && renderCashSelect()}
<button disabled={disabled} type="submit">Добавить</button>
</form>
);

View File

@ -14,6 +14,7 @@ const deletingItem = {
description: 'Mock description',
expenseListId: '10cc093c-3875-4d33-a40a-df526266e262',
id: '51a44dc0-597b-49b9-85bb-e0bcd42db3f6',
payment: null,
title: 'Mock item',
updatedAt: '2025-08-29T07:57:40.503Z',
};

View File

@ -14,6 +14,7 @@ const editingItem = {
description: 'Mock description',
expenseListId: '10cc093c-3875-4d33-a40a-df526266e262',
id: '51a44dc0-597b-49b9-85bb-e0bcd42db3f6',
payment: null,
title: 'Mock item',
updatedAt: '2025-08-29T07:57:40.503Z',
};

View File

@ -1,14 +1,22 @@
import { getBankCardList, getCashList } from '@mocks/browser/handlers';
import { server } from '@mocks/jest/server';
import { createReactQueryWrapper } from '@mocks/jest/wrappers';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { http, HttpResponse } from 'msw';
import type { AddExpenseItemBody, AddExpenseItemPath, AddExpenseItemSuccessResponse } from '@/entity/expenses/item';
import type {
AddExpenseItemBody,
AddExpenseItemPath,
AddExpenseItemSuccessResponse,
} from '@/entity/expenses/item';
import { fillAddExpenseItemForm } from '@/entity/expenses/item/ui/AddExpenseItemForm/AddExpenseItemForm.test';
import AddExpenseItemButton from './AddExpenseItemButton.ui';
const renderButton = () => {
server.use(getBankCardList, getCashList);
render(
<AddExpenseItemButton data-testid="test-button" listId="123" />,
{ wrapper: createReactQueryWrapper() },
@ -31,14 +39,6 @@ const openModal = async () => {
};
};
const fillForm = async (data: { amount: string; count: string; date: string; description: string; title: string }) => {
await userEvent.type(screen.getByLabelText('Название'), data.title);
await userEvent.type(screen.getByLabelText('Описание'), data.description);
await userEvent.type(screen.getByLabelText('Дата'), data.date);
await userEvent.type(screen.getByLabelText('Сумма'), data.amount);
await userEvent.type(screen.getByLabelText('Количество'), data.count);
};
const submitHandler = (requestSpy: jest.Mock) => http.post<
AddExpenseItemPath,
AddExpenseItemBody,
@ -58,6 +58,7 @@ const submitHandler = (requestSpy: jest.Mock) => http.post<
description: 'Mock description',
expenseListId: '10cc093c-3875-4d33-a40a-df526266e262',
id: '51a44dc0-597b-49b9-85bb-e0bcd42db3f6',
payment: null,
title: 'Mock item',
updatedAt: '2025-08-29T07:57:40.503Z',
});
@ -94,7 +95,7 @@ describe('Test AddExpenseItemButton', () => {
modalElem = (await openModal()).modalElem;
submitButtonElem = screen.getByText('Добавить');
await fillForm({
await fillAddExpenseItemForm({
amount: '12',
count: '1',
date: '2025-08-29',
@ -112,6 +113,7 @@ describe('Test AddExpenseItemButton', () => {
currency: 'RUB',
date: '2025-08-29T00:00:00.000Z',
description: 'Описание элемента',
payment: null,
title: 'Название элемента',
});
});

View File

@ -17,6 +17,7 @@ const deletedItem = {
description: 'Mock description',
expenseListId: '10cc093c-3875-4d33-a40a-df526266e262',
id: '51a44dc0-597b-49b9-85bb-e0bcd42db3f6',
payment: null,
title: 'Mock item',
updatedAt: '2025-08-29T07:57:40.503Z',
};

View File

@ -21,6 +21,7 @@ const editingItem = {
description: 'Mock description',
expenseListId: '10cc093c-3875-4d33-a40a-df526266e262',
id: '51a44dc0-597b-49b9-85bb-e0bcd42db3f6',
payment: null,
title: 'Mock item',
updatedAt: '2025-08-29T07:57:40.503Z',
};

View File

@ -486,6 +486,19 @@ export interface components {
*/
title: string;
};
ExpensePaymentDto: {
/**
* @description Способ оплаты
* @example CASH
* @enum {string|null}
*/
type: "BANK_CARD" | "CASH" | null;
/**
* @description ID карты или наличных
* @example 123
*/
id: string | null;
};
ExpenseListItemDto: {
/** @description ID */
id: string;
@ -524,6 +537,8 @@ export interface components {
* @example 2
*/
count: number | null;
/** @description Оплата */
payment: components["schemas"]["ExpensePaymentDto"] | null;
/**
* Format: date-time
* @description Дата создания
@ -598,6 +613,8 @@ export interface components {
* @example 2
*/
count: number | null;
/** @description Оплата */
payment: components["schemas"]["ExpensePaymentDto"] | null;
};
EditExpenseListItemDto: {
/**

View File

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

View File

@ -0,0 +1,4 @@
.wrapper {
display: flex;
flex-direction: column;
}

View File

@ -0,0 +1,45 @@
import type { FC, HTMLProps, RefObject } from 'react';
import classes from './Select.module.css';
type Option = {
label: string;
value: string;
};
type SelectProps = HTMLProps<HTMLSelectElement> & {
label: string;
options: Option[];
ref?: RefObject<HTMLSelectElement>;
};
const Select: FC<SelectProps> = (props) => {
const {
id,
label,
options,
ref,
...refProps
} = props;
return (
<label className={classes.wrapper} htmlFor={id}>
{label}
<select
id={id}
ref={ref}
{...refProps}
>
{options.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</label>
);
};
export default Select;

View File

@ -33,5 +33,8 @@
"eslint.config.ts",
"rsbuild.config.ts",
"jest.config.ts"
],
"exclude": [
"src/shared/api"
]
}