diff --git a/frontend/__mocks__/browser/data/bankCards/list.ts b/frontend/__mocks__/browser/data/bankCards/list.ts new file mode 100644 index 0000000..08c3a1c --- /dev/null +++ b/frontend/__mocks__/browser/data/bankCards/list.ts @@ -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', + }, +]; diff --git a/frontend/__mocks__/browser/data/cash/list.ts b/frontend/__mocks__/browser/data/cash/list.ts new file mode 100644 index 0000000..59b6b0a --- /dev/null +++ b/frontend/__mocks__/browser/data/cash/list.ts @@ -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', + }, +]; diff --git a/frontend/__mocks__/browser/data/expenses/items.ts b/frontend/__mocks__/browser/data/expenses/items.ts index 9c1ba68..72f90e7 100644 --- a/frontend/__mocks__/browser/data/expenses/items.ts +++ b/frontend/__mocks__/browser/data/expenses/items.ts @@ -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', }, diff --git a/frontend/__mocks__/browser/data/index.ts b/frontend/__mocks__/browser/data/index.ts new file mode 100644 index 0000000..449d96e --- /dev/null +++ b/frontend/__mocks__/browser/data/index.ts @@ -0,0 +1,2 @@ +export { bankCardList } from './bankCards/list'; +export { cashList } from './cash/list'; diff --git a/frontend/__mocks__/browser/handlers/bank-cards/list/get.ts b/frontend/__mocks__/browser/handlers/bank-cards/list/get.ts new file mode 100644 index 0000000..b67644d --- /dev/null +++ b/frontend/__mocks__/browser/handlers/bank-cards/list/get.ts @@ -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( + '/bank-card', + () => HttpResponse.json(bankCardList), +); diff --git a/frontend/__mocks__/browser/handlers/cash/list/get.ts b/frontend/__mocks__/browser/handlers/cash/list/get.ts new file mode 100644 index 0000000..d1ef814 --- /dev/null +++ b/frontend/__mocks__/browser/handlers/cash/list/get.ts @@ -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( + '/cash', + () => HttpResponse.json(cashList), +); diff --git a/frontend/__mocks__/browser/handlers/index.ts b/frontend/__mocks__/browser/handlers/index.ts index 433bfdc..a23c3b7 100644 --- a/frontend/__mocks__/browser/handlers/index.ts +++ b/frontend/__mocks__/browser/handlers/index.ts @@ -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'; diff --git a/frontend/eslint.config.ts b/frontend/eslint.config.ts index c79bbf7..0fd21d5 100644 --- a/frontend/eslint.config.ts +++ b/frontend/eslint.config.ts @@ -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', { diff --git a/frontend/src/entity/expenses/item/ui/AddExpenseItemForm/AddExpenseItemForm.test.tsx b/frontend/src/entity/expenses/item/ui/AddExpenseItemForm/AddExpenseItemForm.test.tsx index 97ebd47..c9f1abb 100644 --- a/frontend/src/entity/expenses/item/ui/AddExpenseItemForm/AddExpenseItemForm.test.tsx +++ b/frontend/src/entity/expenses/item/ui/AddExpenseItemForm/AddExpenseItemForm.test.tsx @@ -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) => { - const onSubmit = jest.fn(); - - render(); - +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) => { + server.use(getBankCardList, getCashList); + const onSubmit = jest.fn(); + + render( + , + { wrapper: createReactQueryWrapper() }, + ); + + const { + amountInputElem, + countInputElem, + dateInputElem, + descriptionInputElem, + submitButtonElem, + titleInputElem, + } = getExpenseItemFormElements(); return { amountInputElem, @@ -48,33 +155,96 @@ describe('Test AddExpenseItemForm', () => { Object.values(formElems).forEach(assertion); }); - test('should call onSubmit with values', async () => { - const { - amountInputElem, - countInputElem, - dateInputElem, - descriptionInputElem, - onSubmit, - submitButtonElem, - titleInputElem, - } = renderForm(); + describe('should call onSubmit with values', () => { + const setup = async () => { + const { + onSubmit, + submitButtonElem, + } = 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: 'Название элемента', + }); - await userEvent.click(submitButtonElem); + return { + onSubmit, + submitButtonElem, + }; + }; - expect(onSubmit).toHaveBeenCalledTimes(1); - expect(onSubmit).toHaveBeenCalledWith({ - amount: 12, - count: 1, - currency: 'RUB', - date: '2025-08-29T00:00:00.000Z', - description: 'Описание элемента', - title: 'Название элемента', + 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); + + expect(onSubmit).toHaveBeenCalledTimes(1); + expect(onSubmit).toHaveBeenCalledWith({ + amount: 12, + count: 1, + 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: 'Название элемента', + }); }); }); }); diff --git a/frontend/src/entity/expenses/item/ui/AddExpenseItemForm/AddExpenseItemForm.ui.tsx b/frontend/src/entity/expenses/item/ui/AddExpenseItemForm/AddExpenseItemForm.ui.tsx index 43d6025..10fd1f1 100644 --- a/frontend/src/entity/expenses/item/ui/AddExpenseItemForm/AddExpenseItemForm.ui.tsx +++ b/frontend/src/entity/expenses/item/ui/AddExpenseItemForm/AddExpenseItemForm.ui.tsx @@ -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 = (props) => { ...formProps } = props; + const [paymentType, setPaymentType] = useState['type']>(null); + const onFormSubmitHandler: FormEventHandler = (event) => { event.preventDefault(); - const formData = new FormData(event.currentTarget); const title = formData.get('title'); @@ -44,6 +61,28 @@ const AddExpenseItemForm: FC = (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 = (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 = (event) => { + const { value } = event.target; + if (value === 'none') { + setPaymentType(null); + } else { + // @ts-ignore todo + setPaymentType(value); + } + }; + + const renderCardSelect = () => ( + + ); return (
= (props) => { /> = (props) => { /> + + {options.map((option) => ( + + ))} + + + + ); +}; + +export default Select; diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json index 441a743..3096657 100644 --- a/frontend/tsconfig.json +++ b/frontend/tsconfig.json @@ -33,5 +33,8 @@ "eslint.config.ts", "rsbuild.config.ts", "jest.config.ts" + ], + "exclude": [ + "src/shared/api" ] }