diff --git a/frontend/__mocks__/jest/helpers/enqueueSnackbar.ts b/frontend/__mocks__/jest/helpers/enqueueSnackbar.ts
new file mode 100644
index 0000000..c730165
--- /dev/null
+++ b/frontend/__mocks__/jest/helpers/enqueueSnackbar.ts
@@ -0,0 +1,13 @@
+const mockEnqueueSnackbar = jest.fn();
+
+// eslint-disable-next-line @typescript-eslint/no-unsafe-return
+jest.mock('notistack', () => ({
+ ...jest.requireActual('notistack'),
+ enqueueSnackbar: mockEnqueueSnackbar,
+}));
+
+beforeEach(() => {
+ mockEnqueueSnackbar.mockClear();
+});
+
+export { mockEnqueueSnackbar };
diff --git a/frontend/__mocks__/jest/helpers/index.ts b/frontend/__mocks__/jest/helpers/index.ts
index 5fa8138..5c4356e 100644
--- a/frontend/__mocks__/jest/helpers/index.ts
+++ b/frontend/__mocks__/jest/helpers/index.ts
@@ -1,3 +1,4 @@
+export { mockEnqueueSnackbar } from './enqueueSnackbar';
export { selectOption } from './selectOption';
export { typeDateField } from './typeDateField';
export { typeTextField } from './typeTextField';
diff --git a/frontend/__mocks__/jest/helpers/selectOption.ts b/frontend/__mocks__/jest/helpers/selectOption.ts
index 5143e47..67cd6d2 100644
--- a/frontend/__mocks__/jest/helpers/selectOption.ts
+++ b/frontend/__mocks__/jest/helpers/selectOption.ts
@@ -1,7 +1,7 @@
import { screen, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
-export const selectOption = async (selectElement: HTMLElement | null, value: string) => {
+export const selectOption = async (selectElement: HTMLElement | null, value?: string) => {
if (!selectElement) {
throw new Error('Не найден элемент выпадающего списка');
}
diff --git a/frontend/__mocks__/jest/wrappers/AppWrapper.tsx b/frontend/__mocks__/jest/wrappers/AppWrapper.tsx
index 342cf5f..90e4f1b 100644
--- a/frontend/__mocks__/jest/wrappers/AppWrapper.tsx
+++ b/frontend/__mocks__/jest/wrappers/AppWrapper.tsx
@@ -1,5 +1,7 @@
import type { PropsWithChildren } from 'react';
+import { SnackbarProvider } from 'notistack';
+
import { createLocalizationWrapper } from './LocalizationWrapper';
import { createReactQueryWrapper } from './ReactQueryWrapper';
@@ -10,6 +12,8 @@ export const createAppWrapper = () => ({ children }: PropsWithChildren) => {
return (
+
+
{children}
diff --git a/frontend/configs/jest/setupEnv.ts b/frontend/configs/jest/setupEnv.ts
deleted file mode 100644
index c3daa26..0000000
--- a/frontend/configs/jest/setupEnv.ts
+++ /dev/null
@@ -1 +0,0 @@
-process.env.TZ = 'Europe/Moscow';
diff --git a/frontend/configs/jest/setupTests.ts b/frontend/configs/jest/setupTests.ts
index 4ae9dcf..7603625 100644
--- a/frontend/configs/jest/setupTests.ts
+++ b/frontend/configs/jest/setupTests.ts
@@ -1,8 +1,6 @@
import '@testing-library/jest-dom';
import { server } from '@mocks/jest/server';
-import './setupEnv';
-
beforeAll(() => {
server.listen();
});
@@ -12,3 +10,14 @@ afterEach(() => {
afterAll(() => {
server.close();
});
+
+beforeEach(() => {
+ jest.useFakeTimers({
+ advanceTimers: 0.1,
+ now: new Date('2025-01-01T00:00:00.000Z'),
+ });
+});
+
+afterAll(() => {
+ jest.useRealTimers();
+});
diff --git a/frontend/eslint.config.ts b/frontend/eslint.config.ts
index 0fd21d5..eeb46a7 100644
--- a/frontend/eslint.config.ts
+++ b/frontend/eslint.config.ts
@@ -12,6 +12,8 @@ export default [
rules: {
'@stylistic/jsx-sort-props': 'off',
'@typescript-eslint/no-floating-promises': 'off',
+ '@typescript-eslint/no-misused-promises': 'off',
+ '@typescript-eslint/prefer-nullish-coalescing': 'off',
'@typescript-eslint/sort-type-constituents': 'off',
'@typescript-eslint/strict-boolean-expressions': 'off',
'func-style': 'off',
diff --git a/frontend/package-lock.json b/frontend/package-lock.json
index b086ee4..48bf33e 100644
--- a/frontend/package-lock.json
+++ b/frontend/package-lock.json
@@ -11,6 +11,7 @@
"@emotion/react": "11.14.0",
"@emotion/styled": "11.14.1",
"@fontsource/roboto": "5.2.8",
+ "@hookform/resolvers": "5.2.2",
"@mui/icons-material": "7.3.4",
"@mui/material": "7.3.4",
"@mui/x-date-pickers": "8.12.0",
@@ -18,9 +19,12 @@
"axios": "1.12.2",
"date-fns": "4.1.0",
"date-fns-tz": "3.2.0",
+ "notistack": "3.0.2",
"react": "19.2.0",
"react-dom": "19.2.0",
- "react-router": "7.9.4"
+ "react-hook-form": "7.64.0",
+ "react-router": "7.9.4",
+ "zod": "4.1.12"
},
"devDependencies": {
"@rsbuild/core": "1.5.14",
@@ -1102,6 +1106,18 @@
"url": "https://github.com/sponsors/ayuhito"
}
},
+ "node_modules/@hookform/resolvers": {
+ "version": "5.2.2",
+ "resolved": "https://registry.npmjs.org/@hookform/resolvers/-/resolvers-5.2.2.tgz",
+ "integrity": "sha512-A/IxlMLShx3KjV/HeTcTfaMxdwy690+L/ZADoeaTltLx+CVuzkeVIPuybK3jrRfw7YZnmdKsVVHAlEPIAEUNlA==",
+ "license": "MIT",
+ "dependencies": {
+ "@standard-schema/utils": "^0.3.0"
+ },
+ "peerDependencies": {
+ "react-hook-form": "^7.55.0"
+ }
+ },
"node_modules/@humanfs/core": {
"version": "0.19.1",
"resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz",
@@ -3582,6 +3598,12 @@
"@sinonjs/commons": "^3.0.1"
}
},
+ "node_modules/@standard-schema/utils": {
+ "version": "0.3.0",
+ "resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz",
+ "integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==",
+ "license": "MIT"
+ },
"node_modules/@stylistic/eslint-plugin": {
"version": "5.3.1",
"resolved": "https://registry.npmjs.org/@stylistic/eslint-plugin/-/eslint-plugin-5.3.1.tgz",
@@ -7969,6 +7991,15 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/goober": {
+ "version": "2.1.18",
+ "resolved": "https://registry.npmjs.org/goober/-/goober-2.1.18.tgz",
+ "integrity": "sha512-2vFqsaDVIT9Gz7N6kAL++pLpp41l3PfDuusHcjnGLfR6+huZkl6ziX+zgVC3ZxpqWhzH6pyDdGrCeDhMIvwaxw==",
+ "license": "MIT",
+ "peerDependencies": {
+ "csstype": "^3.0.10"
+ }
+ },
"node_modules/gopd": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
@@ -11359,6 +11390,37 @@
"node": ">=0.10.0"
}
},
+ "node_modules/notistack": {
+ "version": "3.0.2",
+ "resolved": "https://registry.npmjs.org/notistack/-/notistack-3.0.2.tgz",
+ "integrity": "sha512-0R+/arLYbK5Hh7mEfR2adt0tyXJcCC9KkA2hc56FeWik2QN6Bm/S4uW+BjzDARsJth5u06nTjelSw/VSnB1YEA==",
+ "license": "MIT",
+ "dependencies": {
+ "clsx": "^1.1.0",
+ "goober": "^2.0.33"
+ },
+ "engines": {
+ "node": ">=12.0.0",
+ "npm": ">=6.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/notistack"
+ },
+ "peerDependencies": {
+ "react": "^17.0.0 || ^18.0.0 || ^19.0.0",
+ "react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0"
+ }
+ },
+ "node_modules/notistack/node_modules/clsx": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/clsx/-/clsx-1.2.1.tgz",
+ "integrity": "sha512-EcR6r5a8bj6pu3ycsa/E/cKVGuTgZJZdsyUYHOksG/UHIiKfjxzRxYJpyVBwYaQeOvghal9fcc4PidlgzugAQg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
"node_modules/npm-run-path": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-4.0.1.tgz",
@@ -12041,6 +12103,22 @@
"react": "^19.2.0"
}
},
+ "node_modules/react-hook-form": {
+ "version": "7.64.0",
+ "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.64.0.tgz",
+ "integrity": "sha512-fnN+vvTiMLnRqKNTVhDysdrUay0kUUAymQnFIznmgDvapjveUWOOPqMNzPg+A+0yf9DuE2h6xzBjN1s+Qx8wcg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=18.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/react-hook-form"
+ },
+ "peerDependencies": {
+ "react": "^16.8.0 || ^17 || ^18 || ^19"
+ }
+ },
"node_modules/react-is": {
"version": "16.13.1",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
@@ -14180,6 +14258,15 @@
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
+ },
+ "node_modules/zod": {
+ "version": "4.1.12",
+ "resolved": "https://registry.npmjs.org/zod/-/zod-4.1.12.tgz",
+ "integrity": "sha512-JInaHOamG8pt5+Ey8kGmdcAcg3OL9reK8ltczgHTAwNhMys/6ThXHityHxVV2p3fkw/c+MAvBHFVYHFZDmjMCQ==",
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/colinhacks"
+ }
}
}
}
diff --git a/frontend/package.json b/frontend/package.json
index 78fca24..d2edd56 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -17,6 +17,7 @@
"@emotion/react": "11.14.0",
"@emotion/styled": "11.14.1",
"@fontsource/roboto": "5.2.8",
+ "@hookform/resolvers": "5.2.2",
"@mui/icons-material": "7.3.4",
"@mui/material": "7.3.4",
"@mui/x-date-pickers": "8.12.0",
@@ -24,9 +25,12 @@
"axios": "1.12.2",
"date-fns": "4.1.0",
"date-fns-tz": "3.2.0",
+ "notistack": "3.0.2",
"react": "19.2.0",
"react-dom": "19.2.0",
- "react-router": "7.9.4"
+ "react-hook-form": "7.64.0",
+ "react-router": "7.9.4",
+ "zod": "4.1.12"
},
"devDependencies": {
"@rsbuild/core": "1.5.14",
diff --git a/frontend/src/app/App/ui/App.tsx b/frontend/src/app/App/ui/App.tsx
index 17565da..84cd8cc 100644
--- a/frontend/src/app/App/ui/App.tsx
+++ b/frontend/src/app/App/ui/App.tsx
@@ -1,17 +1,25 @@
-import './normalize.css';
+import CssBaseline from '@mui/material/CssBaseline';
import { LocalizationProvider } from '@mui/x-date-pickers';
import { AdapterDateFns } from '@mui/x-date-pickers/AdapterDateFns';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { ru } from 'date-fns/locale/ru';
+import { SnackbarProvider } from 'notistack';
import { AuthContextProvider } from '@/shared/context/AuthContext';
+import { setupZod } from '@/shared/utils';
import Routing from './Routing';
+setupZod();
+
const queryClient = new QueryClient();
const App = () => (
+
+
+
+
diff --git a/frontend/src/app/App/ui/normalize.css b/frontend/src/app/App/ui/normalize.css
deleted file mode 100644
index b7104b6..0000000
--- a/frontend/src/app/App/ui/normalize.css
+++ /dev/null
@@ -1,353 +0,0 @@
-/*! normalize.css v8.0.1 | MIT License | github.com/necolas/normalize.css */
-
-/* Document
- ========================================================================== */
-
-/**
- * 1. Correct the line height in all browsers.
- * 2. Prevent adjustments of font size after orientation changes in iOS.
- */
-
-html {
- line-height: 1.15; /* 1 */
- -webkit-text-size-adjust: 100%; /* 2 */
-}
-
-/* Sections
- ========================================================================== */
-
-/**
- * Remove the margin in all browsers.
- */
-
-body {
- margin: 0;
-}
-
-/**
- * Render the `main` element consistently in IE.
- */
-
-main {
- display: block;
-}
-
-/**
- * Correct the font size and margin on `h1` elements within `section` and
- * `article` contexts in Chrome, Firefox, and Safari.
- */
-
-h1 {
- font-size: 2em;
- margin: 0.67em 0;
-}
-
-/* Grouping content
- ========================================================================== */
-
-/**
- * 1. Add the correct box sizing in Firefox.
- * 2. Show the overflow in Edge and IE.
- */
-
-hr {
- box-sizing: content-box; /* 1 */
- height: 0; /* 1 */
- overflow: visible; /* 2 */
-}
-
-/**
- * 1. Correct the inheritance and scaling of font size in all browsers.
- * 2. Correct the odd `em` font sizing in all browsers.
- */
-
-pre {
- font-family: monospace, monospace; /* 1 */
- font-size: 1em; /* 2 */
-}
-
-/* Text-level semantics
- ========================================================================== */
-
-/**
- * Remove the gray background on active links in IE 10.
- */
-
-a {
- background-color: transparent;
-}
-
-/**
- * 1. Remove the bottom border in Chrome 57-
- * 2. Add the correct text decoration in Chrome, Edge, IE, Opera, and Safari.
- */
-
-abbr[title] {
- border-bottom: none; /* 1 */
- text-decoration: underline; /* 2 */
- text-decoration: underline dotted; /* 2 */
-}
-
-/**
- * Add the correct font weight in Chrome, Edge, and Safari.
- */
-
-b,
-strong {
- font-weight: bolder;
-}
-
-/**
- * 1. Correct the inheritance and scaling of font size in all browsers.
- * 2. Correct the odd `em` font sizing in all browsers.
- */
-
-code,
-kbd,
-samp {
- font-family: monospace, monospace; /* 1 */
- font-size: 1em; /* 2 */
-}
-
-/**
- * Add the correct font size in all browsers.
- */
-
-small {
- font-size: 80%;
-}
-
-/**
- * Prevent `sub` and `sup` elements from affecting the line height in
- * all browsers.
- */
-
-sub,
-sup {
- font-size: 75%;
- line-height: 0;
- position: relative;
- vertical-align: baseline;
-}
-
-sub {
- bottom: -0.25em;
-}
-
-sup {
- top: -0.5em;
-}
-
-/* Embedded content
- ========================================================================== */
-
-/**
- * Remove the border on images inside links in IE 10.
- */
-
-img {
- border-style: none;
-}
-
-/* Forms
- ========================================================================== */
-
-/**
- * 1. Change the font styles in all browsers.
- * 2. Remove the margin in Firefox and Safari.
- */
-
-button,
-input,
-optgroup,
-select,
-textarea {
- font-family: inherit; /* 1 */
- font-size: 100%; /* 1 */
- line-height: 1.15; /* 1 */
- margin: 0; /* 2 */
-}
-
-/**
- * Show the overflow in IE.
- * 1. Show the overflow in Edge.
- */
-
-button,
-input { /* 1 */
- overflow: visible;
-}
-
-/**
- * Remove the inheritance of text transform in Edge, Firefox, and IE.
- * 1. Remove the inheritance of text transform in Firefox.
- */
-
-button,
-select { /* 1 */
- text-transform: none;
-}
-
-/**
- * Correct the inability to style clickable types in iOS and Safari.
- */
-
-button,
-[type="button"],
-[type="reset"],
-[type="submit"] {
- -webkit-appearance: button;
-}
-
-/**
- * Remove the inner border and padding in Firefox.
- */
-
-button::-moz-focus-inner,
-[type="button"]::-moz-focus-inner,
-[type="reset"]::-moz-focus-inner,
-[type="submit"]::-moz-focus-inner {
- border-style: none;
- padding: 0;
-}
-
-/**
- * Restore the focus styles unset by the previous rule.
- */
-
-button:-moz-focusring,
-[type="button"]:-moz-focusring,
-[type="reset"]:-moz-focusring,
-[type="submit"]:-moz-focusring {
- outline: 1px dotted ButtonText;
-}
-
-/**
- * Correct the padding in Firefox.
- */
-
-fieldset {
- padding: 0.35em 0.75em 0.625em;
-}
-
-/**
- * 1. Correct the text wrapping in Edge and IE.
- * 2. Correct the color inheritance from `fieldset` elements in IE.
- * 3. Remove the padding so developers are not caught out when they zero out
- * `fieldset` elements in all browsers.
- */
-
-legend {
- box-sizing: border-box; /* 1 */
- color: inherit; /* 2 */
- display: table; /* 1 */
- max-width: 100%; /* 1 */
- padding: 0; /* 3 */
- white-space: normal; /* 1 */
-}
-
-/**
- * Add the correct vertical alignment in Chrome, Firefox, and Opera.
- */
-
-progress {
- vertical-align: baseline;
-}
-
-/**
- * Remove the default vertical scrollbar in IE 10+.
- */
-
-textarea {
- overflow: auto;
-}
-
-/**
- * 1. Add the correct box sizing in IE 10.
- * 2. Remove the padding in IE 10.
- */
-
-[type="checkbox"],
-[type="radio"] {
- box-sizing: border-box; /* 1 */
- padding: 0; /* 2 */
-}
-
-/**
- * Correct the cursor style of increment and decrement buttons in Chrome.
- */
-
-[type="number"]::-webkit-inner-spin-button,
-[type="number"]::-webkit-outer-spin-button {
- height: auto;
-}
-
-/**
- * 1. Correct the odd appearance in Chrome and Safari.
- * 2. Correct the outline style in Safari.
- */
-
-[type="search"] {
- -webkit-appearance: textfield; /* 1 */
- outline-offset: -2px; /* 2 */
-}
-
-/**
- * Remove the inner padding in Chrome and Safari on macOS.
- */
-
-[type="search"]::-webkit-search-decoration {
- -webkit-appearance: none;
-}
-
-/**
- * 1. Correct the inability to style clickable types in iOS and Safari.
- * 2. Change font properties to `inherit` in Safari.
- */
-
-::-webkit-file-upload-button {
- -webkit-appearance: button; /* 1 */
- font: inherit; /* 2 */
-}
-
-/* Interactive
- ========================================================================== */
-
-/*
- * Add the correct display in Edge, IE 10+, and Firefox.
- */
-
-details {
- display: block;
-}
-
-/*
- * Add the correct display in all browsers.
- */
-
-summary {
- display: list-item;
-}
-
-/* Misc
- ========================================================================== */
-
-/**
- * Add the correct display in IE 10+.
- */
-
-template {
- display: none;
-}
-
-/**
- * Add the correct display in IE 10.
- */
-
-[hidden] {
- display: none;
-}
-
-* {
- box-sizing: border-box;
-}
diff --git a/frontend/src/entity/expenses/item/ui/AddExpenseItemForm/index.ts b/frontend/src/entity/expenses/item/ui/AddExpenseItemForm/index.ts
new file mode 100644
index 0000000..27dbfde
--- /dev/null
+++ b/frontend/src/entity/expenses/item/ui/AddExpenseItemForm/index.ts
@@ -0,0 +1,2 @@
+export type { AddExpenseItemFormProps, AddExpenseItemFormValues } from './AddExpenseItemForm.ui';
+export { default as AddExpenseItemForm } from './AddExpenseItemForm.ui';
diff --git a/frontend/src/entity/expenses/item/ui/EditExpenseItemForm/EditExpenseItemForm.ui.tsx b/frontend/src/entity/expenses/item/ui/EditExpenseItemForm/EditExpenseItemForm.ui.tsx
index 34d229c..a46fa8d 100644
--- a/frontend/src/entity/expenses/item/ui/EditExpenseItemForm/EditExpenseItemForm.ui.tsx
+++ b/frontend/src/entity/expenses/item/ui/EditExpenseItemForm/EditExpenseItemForm.ui.tsx
@@ -41,7 +41,6 @@ const EditExpenseItemForm: FC = (props) => {
...formProps
} = props;
- // eslint-disable-next-line @typescript-eslint/no-misused-promises
const onFormSubmitHandler: FormEventHandler = async (event) => {
event.preventDefault();
diff --git a/frontend/src/entity/incomes/item/index.ts b/frontend/src/entity/incomes/item/index.ts
index a9d46bb..ff10452 100644
--- a/frontend/src/entity/incomes/item/index.ts
+++ b/frontend/src/entity/incomes/item/index.ts
@@ -7,13 +7,7 @@ export { useDeleteIncomeItem } from './hooks/useDeleteIncomeItem';
export { useEditIncomeItem } from './hooks/useEditIncomeItem';
export type { IncomeListItem } from './types';
-export type { AddIncomeItemFormValues } from './ui/AddIncomeItemForm/AddIncomeItemForm.ui';
-export type { AddIncomeItemFormProps } from './ui/AddIncomeItemForm/AddIncomeItemForm.ui';
-export { default as AddIncomeItemForm } from './ui/AddIncomeItemForm/AddIncomeItemForm.ui';
-export type { DeleteIncomeItemFormProps } from './ui/DeleteIncomeItemForm/DeleteIncomeItemForm.ui';
-export { default as DeleteIncomeItemForm } from './ui/DeleteIncomeItemForm/DeleteIncomeItemForm.ui';
-
-export type { EditIncomeItemFormProps, EditIncomeItemFormValues } from './ui/EditIncomeItemForm/EditIncomeItemForm.ui';
-
-export { default as EditIncomeItemForm } from './ui/EditIncomeItemForm/EditIncomeItemForm.ui';
+export * from './ui/AddIncomeItemForm';
+export * from './ui/DeleteIncomeItemForm';
+export * from './ui/EditIncomeItemForm';
diff --git a/frontend/src/entity/incomes/item/ui/AddIncomeItemForm/AddIncomeItemForm.module.css b/frontend/src/entity/incomes/item/ui/AddIncomeItemForm/AddIncomeItemForm.module.css
index a97ffec..2f295bc 100644
--- a/frontend/src/entity/incomes/item/ui/AddIncomeItemForm/AddIncomeItemForm.module.css
+++ b/frontend/src/entity/incomes/item/ui/AddIncomeItemForm/AddIncomeItemForm.module.css
@@ -1,5 +1,5 @@
.form {
display: flex;
flex-direction: column;
- gap: 10px;
+ gap: 14px;
}
diff --git a/frontend/src/entity/incomes/item/ui/AddIncomeItemForm/AddIncomeItemForm.schema.ts b/frontend/src/entity/incomes/item/ui/AddIncomeItemForm/AddIncomeItemForm.schema.ts
new file mode 100644
index 0000000..6915b0d
--- /dev/null
+++ b/frontend/src/entity/incomes/item/ui/AddIncomeItemForm/AddIncomeItemForm.schema.ts
@@ -0,0 +1,13 @@
+import * as z from 'zod';
+
+export const AddIncomeItemFormSchema = z.object({
+ amount: z.number(),
+ 'bank-card': z.string().optional(),
+ cash: z.string().optional(),
+ date: z.date(),
+ description: z.string().trim().optional(),
+ targetType: z.enum(['BANK_CARD', 'CASH', 'none']),
+ title: z.string().trim().min(5),
+});
+
+export type AddIncomeItemFormValues = z.infer;
diff --git a/frontend/src/entity/incomes/item/ui/AddIncomeItemForm/AddIncomeItemForm.test.tsx b/frontend/src/entity/incomes/item/ui/AddIncomeItemForm/AddIncomeItemForm.test.tsx
index a0a3e69..66d5e51 100644
--- a/frontend/src/entity/incomes/item/ui/AddIncomeItemForm/AddIncomeItemForm.test.tsx
+++ b/frontend/src/entity/incomes/item/ui/AddIncomeItemForm/AddIncomeItemForm.test.tsx
@@ -1,3 +1,4 @@
+import { bankCardList, cashList } from '@mocks/browser/data';
import { AddIncomeItemFormHelper } from '@tests/helpers';
describe('Test AddIncomeItemForm', () => {
@@ -13,27 +14,95 @@ describe('Test AddIncomeItemForm', () => {
['disabled', { disabled: true }, (el: HTMLElement) => { expect(el).toBeDisabled(); }],
])('should render fields %s', (_state, options, assertion) => {
AddIncomeItemFormHelper.renderForm(options);
- const { formElem, ...formElems } = AddIncomeItemFormHelper.getFormElements();
- Object.values(formElems).forEach(assertion);
+ const elems = AddIncomeItemFormHelper.getFormElements();
+ Object.values([
+ elems.amountInputElem,
+ elems.dateInputElem,
+ elems.descriptionInputElem,
+ elems.submitButtonElem,
+ elems.titleInputElem,
+ ]).forEach(assertion);
});
- test('should call onSubmit with values', async () => {
- const { onSubmit } = AddIncomeItemFormHelper.renderForm();
+ describe('should call onSubmit with values', () => {
+ const setup = async () => {
+ const { onSubmit } = AddIncomeItemFormHelper.renderForm();
- await AddIncomeItemFormHelper.fillForm({
- amount: 12,
- date: '29.08.2025',
- description: 'Описание элемента',
- title: 'Название элемента',
+ await AddIncomeItemFormHelper.fillForm(
+ {
+ amount: 12,
+ date: '29.08.2025',
+ description: 'Описание элемента',
+ targetType: 'none',
+ title: 'Название элемента',
+ },
+ { autoSubmit: false },
+ );
+
+ return {
+ onSubmit,
+ };
+ };
+
+ test('no payment', async () => {
+ const { onSubmit } = await setup();
+ await AddIncomeItemFormHelper.submitForm();
+
+ expect(onSubmit).toHaveBeenCalledTimes(1);
+ expect(onSubmit).toHaveBeenCalledWith({
+ amount: 12,
+ date: new Date('2025-08-29T00:00:00.000Z'),
+ description: 'Описание элемента',
+ targetType: 'none',
+ title: 'Название элемента',
+ });
});
- expect(onSubmit).toHaveBeenCalledTimes(1);
- expect(onSubmit).toHaveBeenCalledWith({
- amount: 12,
- currency: 'RUB',
- date: '2025-08-29T00:00:00.000Z',
- description: 'Описание элемента',
- title: 'Название элемента',
+ test('bank card payment', async () => {
+ const { onSubmit } = await setup();
+ await AddIncomeItemFormHelper.fillForm({ targetType: 'BANK_CARD' }, { autoSubmit: false });
+
+ const { bankCardSelect, bankCardSelectInput } = AddIncomeItemFormHelper.getFormElements();
+ expect(bankCardSelect).toBeVisible();
+ expect(bankCardSelectInput).toBeEnabled();
+ expect(bankCardSelectInput).toHaveValue(bankCardList[0].id);
+
+ await AddIncomeItemFormHelper.fillForm({ 'bank-card': bankCardList[1].id, targetType: 'BANK_CARD' }, { autoSubmit: false });
+ expect(bankCardSelectInput).toHaveValue(bankCardList[1].id);
+
+ await AddIncomeItemFormHelper.submitForm();
+
+ expect(onSubmit).toHaveBeenCalledTimes(1);
+ expect(onSubmit).toHaveBeenCalledWith({
+ amount: 12,
+ 'bank-card': bankCardList[1].id,
+ date: new Date('2025-08-29T00:00:00.000Z'),
+ description: 'Описание элемента',
+ targetType: 'BANK_CARD',
+ title: 'Название элемента',
+ });
+ });
+
+ test('cash payment', async () => {
+ const { onSubmit } = await setup();
+
+ await AddIncomeItemFormHelper.fillForm({ targetType: 'CASH' });
+ const { cashSelect, cashSelectInput } = AddIncomeItemFormHelper.getFormElements();
+ expect(cashSelect).toBeVisible();
+ expect(cashSelectInput).toBeEnabled();
+ expect(cashSelectInput).toHaveValue(cashList[0].id);
+
+ await AddIncomeItemFormHelper.fillForm({ cash: cashList[1].id, targetType: 'CASH' });
+
+ expect(onSubmit).toHaveBeenCalledTimes(1);
+ expect(onSubmit).toHaveBeenCalledWith({
+ amount: 12,
+ cash: cashList[1].id,
+ date: new Date('2025-08-29T00:00:00.000Z'),
+ description: 'Описание элемента',
+ targetType: 'CASH',
+ title: 'Название элемента',
+ });
});
});
});
diff --git a/frontend/src/entity/incomes/item/ui/AddIncomeItemForm/AddIncomeItemForm.ui.tsx b/frontend/src/entity/incomes/item/ui/AddIncomeItemForm/AddIncomeItemForm.ui.tsx
index 3cd05f2..de29c69 100644
--- a/frontend/src/entity/incomes/item/ui/AddIncomeItemForm/AddIncomeItemForm.ui.tsx
+++ b/frontend/src/entity/incomes/item/ui/AddIncomeItemForm/AddIncomeItemForm.ui.tsx
@@ -1,32 +1,30 @@
import type {
FC,
- FormEventHandler,
FormHTMLAttributes,
RefObject,
} from 'react';
-import { Button, TextField, Typography } from '@mui/material';
-import { DatePicker } from '@mui/x-date-pickers';
+import { Button, Typography } from '@mui/material';
+import { FormProvider } from 'react-hook-form';
import { DataTestId } from '@/shared/constants';
-import { getUTCDate } from '@/shared/utils';
+
+import type { UseAddIncomeItemFormArgs } from './hooks/useAddIncomeItemForm';
import classes from './AddIncomeItemForm.module.css';
+import AmountField from './fields/AmountField';
+import BankCardField from './fields/BankCardField';
+import CashField from './fields/CashField';
+import DateField from './fields/DateField';
+import DescriptionField from './fields/DescriptionField';
+import TargetTypeField from './fields/TargetTypeField';
+import TitleField from './fields/TitleField';
+import { useAddIncomeItemForm } from './hooks/useAddIncomeItemForm';
-export type AddIncomeItemFormProps = BaseFormProps & {
- disabled?: boolean;
- onSubmit?: (data: AddIncomeItemFormValues) => Promise | void;
+export type AddIncomeItemFormProps = BaseFormProps & UseAddIncomeItemFormArgs & {
ref?: RefObject;
};
-export type AddIncomeItemFormValues = {
- amount: number;
- currency: string;
- date: string;
- description: string;
- title: string;
-};
-
type BaseFormProps = Omit, 'onSubmit'>;
const AddIncomeItemForm: FC = (props) => {
@@ -37,82 +35,53 @@ const AddIncomeItemForm: FC = (props) => {
...formProps
} = props;
- const onFormSubmitHandler: FormEventHandler = (event) => {
- event.preventDefault();
-
- const formData = new FormData(event.currentTarget);
-
- const title = formData.get('title');
- const date = formData.get('date');
- const amount = formData.get('amount');
- const description = formData.get('description');
-
- if (typeof title === 'string' && typeof date === 'string' && typeof amount === 'string' && typeof description === 'string') {
- onSubmit?.({
- amount: Number(amount),
- currency: 'RUB',
- date: getUTCDate(date),
- description,
- title,
- });
- }
- };
+ const {
+ formState,
+ handleSubmit,
+ methods,
+ onFormSubmitHandler,
+ watch,
+ } = useAddIncomeItemForm({ disabled, onSubmit });
return (
-
+
+ Добавить элемент дохода
+
+
+
+
+
+
+
+
+
+
+
+
+ {watch('targetType') === 'BANK_CARD' && }
+
+ {watch('targetType') === 'CASH' && }
+
+
+
+
);
};
diff --git a/frontend/src/entity/incomes/item/ui/AddIncomeItemForm/fields/AmountField.tsx b/frontend/src/entity/incomes/item/ui/AddIncomeItemForm/fields/AmountField.tsx
new file mode 100644
index 0000000..85b7193
--- /dev/null
+++ b/frontend/src/entity/incomes/item/ui/AddIncomeItemForm/fields/AmountField.tsx
@@ -0,0 +1,33 @@
+import { TextField } from '@mui/material';
+import { Controller, useFormContext } from 'react-hook-form';
+
+import type { AddIncomeItemFormValues } from '../AddIncomeItemForm.schema';
+
+const AmountField = () => {
+ const { control, formState } = useFormContext>();
+
+ return (
+ (
+ {
+ field.onChange(Number(event.target.value));
+ }}
+ required
+ size="small"
+ type="number"
+ />
+ )}
+ />
+ );
+};
+
+export default AmountField;
diff --git a/frontend/src/entity/incomes/item/ui/AddIncomeItemForm/fields/BankCardField.tsx b/frontend/src/entity/incomes/item/ui/AddIncomeItemForm/fields/BankCardField.tsx
new file mode 100644
index 0000000..ca58b7d
--- /dev/null
+++ b/frontend/src/entity/incomes/item/ui/AddIncomeItemForm/fields/BankCardField.tsx
@@ -0,0 +1,58 @@
+import {
+ FormControl,
+ FormHelperText,
+ InputLabel,
+ MenuItem,
+ Select,
+} from '@mui/material';
+import { Controller, useFormContext } from 'react-hook-form';
+
+import { useBankCards } from '@/entity/cards';
+import { formatRub } from '@/shared/utils';
+
+import type { AddIncomeItemFormValues } from '../AddIncomeItemForm.schema';
+
+const BankCardField = () => {
+ const { control, formState } = useFormContext>();
+
+ const { data: bankCards } = useBankCards();
+
+ const bankCardOptions = bankCards?.map((card) => (
+
+ ));
+
+ return (
+ (
+
+ Карта
+
+
+
+
+ {formState.errors['bank-card']?.message}
+
+
+ )}
+ />
+ );
+};
+
+export default BankCardField;
diff --git a/frontend/src/entity/incomes/item/ui/AddIncomeItemForm/fields/CashField.tsx b/frontend/src/entity/incomes/item/ui/AddIncomeItemForm/fields/CashField.tsx
new file mode 100644
index 0000000..3ac4396
--- /dev/null
+++ b/frontend/src/entity/incomes/item/ui/AddIncomeItemForm/fields/CashField.tsx
@@ -0,0 +1,57 @@
+import {
+ FormControl,
+ FormHelperText,
+ InputLabel,
+ MenuItem,
+ Select,
+} from '@mui/material';
+import { Controller, useFormContext } from 'react-hook-form';
+
+import { useCash } from '@/entity/cash';
+import { formatRub } from '@/shared/utils';
+
+import type { AddIncomeItemFormValues } from '../AddIncomeItemForm.schema';
+
+const CashField = () => {
+ const { control, formState } = useFormContext>();
+ const { data: cash } = useCash();
+
+ const cashOptions = cash?.map((cashItem) => (
+
+ ));
+
+ return (
+ (
+
+ Наличные
+
+
+
+
+ {formState.errors.cash?.message}
+
+
+ )}
+ />
+ );
+};
+
+export default CashField;
diff --git a/frontend/src/entity/incomes/item/ui/AddIncomeItemForm/fields/DateField.tsx b/frontend/src/entity/incomes/item/ui/AddIncomeItemForm/fields/DateField.tsx
new file mode 100644
index 0000000..d7af5a3
--- /dev/null
+++ b/frontend/src/entity/incomes/item/ui/AddIncomeItemForm/fields/DateField.tsx
@@ -0,0 +1,37 @@
+import type { FC } from 'react';
+
+import { DatePicker } from '@mui/x-date-pickers';
+import { Controller, useFormContext } from 'react-hook-form';
+
+import type { AddIncomeItemFormValues } from '@/entity/incomes/item';
+
+const DateField: FC = () => {
+ const { control, formState } = useFormContext>();
+
+ return (
+ (
+
+ )}
+ />
+ );
+};
+
+export default DateField;
diff --git a/frontend/src/entity/incomes/item/ui/AddIncomeItemForm/fields/DescriptionField.tsx b/frontend/src/entity/incomes/item/ui/AddIncomeItemForm/fields/DescriptionField.tsx
new file mode 100644
index 0000000..9d095f2
--- /dev/null
+++ b/frontend/src/entity/incomes/item/ui/AddIncomeItemForm/fields/DescriptionField.tsx
@@ -0,0 +1,29 @@
+import type { FC } from 'react';
+
+import { TextField } from '@mui/material';
+import { Controller, useFormContext } from 'react-hook-form';
+
+import type { AddIncomeItemFormValues } from '../AddIncomeItemForm.schema';
+
+const DescriptionField: FC = () => {
+ const { control, formState } = useFormContext>();
+
+ return (
+ (
+
+ )}
+ />
+ );
+};
+
+export default DescriptionField;
diff --git a/frontend/src/entity/incomes/item/ui/AddIncomeItemForm/fields/TargetTypeField.tsx b/frontend/src/entity/incomes/item/ui/AddIncomeItemForm/fields/TargetTypeField.tsx
new file mode 100644
index 0000000..47ce5cb
--- /dev/null
+++ b/frontend/src/entity/incomes/item/ui/AddIncomeItemForm/fields/TargetTypeField.tsx
@@ -0,0 +1,69 @@
+import type { FC } from 'react';
+
+import {
+ FormControl,
+ FormHelperText,
+ InputLabel,
+ MenuItem,
+ Select,
+} from '@mui/material';
+import { useMemo } from 'react';
+import { Controller, useFormContext } from 'react-hook-form';
+
+import { useBankCards } from '@/entity/cards';
+import { useCash } from '@/entity/cash';
+
+import type { AddIncomeItemFormValues } from '../AddIncomeItemForm.schema';
+
+const TargetTypeField: FC = () => {
+ const { control, formState } = useFormContext>();
+
+ const { data: bankCards } = useBankCards();
+ const { data: cash } = useCash();
+
+ const targetTypeOptions = useMemo(() => {
+ const options = [];
+
+ if (bankCards && bankCards.length > 0) {
+ options.push();
+ }
+
+ if (cash && cash.length > 0) {
+ options.push();
+ }
+
+ return options;
+ }, [bankCards, cash]);
+
+ return (
+ (
+
+ Способ получения
+
+
+
+
+ {formState.errors.targetType?.message}
+
+
+ )}
+ />
+ );
+};
+
+export default TargetTypeField;
diff --git a/frontend/src/entity/incomes/item/ui/AddIncomeItemForm/fields/TitleField.tsx b/frontend/src/entity/incomes/item/ui/AddIncomeItemForm/fields/TitleField.tsx
new file mode 100644
index 0000000..7bd5213
--- /dev/null
+++ b/frontend/src/entity/incomes/item/ui/AddIncomeItemForm/fields/TitleField.tsx
@@ -0,0 +1,30 @@
+import type { FC } from 'react';
+
+import { TextField } from '@mui/material';
+import { Controller, useFormContext } from 'react-hook-form';
+
+import type { AddIncomeItemFormValues } from '../AddIncomeItemForm.schema';
+
+const TitleField: FC = () => {
+ const { control, formState } = useFormContext>();
+
+ return (
+ (
+
+ )}
+ />
+ );
+};
+
+export default TitleField;
diff --git a/frontend/src/entity/incomes/item/ui/AddIncomeItemForm/hooks/useAddIncomeItemForm.ts b/frontend/src/entity/incomes/item/ui/AddIncomeItemForm/hooks/useAddIncomeItemForm.ts
new file mode 100644
index 0000000..d7fbb53
--- /dev/null
+++ b/frontend/src/entity/incomes/item/ui/AddIncomeItemForm/hooks/useAddIncomeItemForm.ts
@@ -0,0 +1,47 @@
+import type { SubmitHandler } from 'react-hook-form';
+
+import { zodResolver } from '@hookform/resolvers/zod';
+import { useForm } from 'react-hook-form';
+
+import type { AddIncomeItemFormValues } from '../AddIncomeItemForm.schema';
+
+import { AddIncomeItemFormSchema } from '../AddIncomeItemForm.schema';
+
+export type UseAddIncomeItemFormArgs = {
+ disabled?: boolean;
+ onSubmit: (data: AddIncomeItemFormValues) => Promise | void;
+};
+
+export const useAddIncomeItemForm = (args: UseAddIncomeItemFormArgs) => {
+ const { disabled, onSubmit } = args;
+
+ const methods = useForm({
+ defaultValues: {
+ amount: 0,
+ date: new Date(),
+ description: '',
+ targetType: 'none',
+ title: '',
+ },
+ disabled,
+ resolver: zodResolver(AddIncomeItemFormSchema),
+ shouldUnregister: true,
+ });
+ const {
+ formState,
+ handleSubmit,
+ watch,
+ } = methods;
+
+ const onFormSubmitHandler: SubmitHandler = async (data) => {
+ await onSubmit(data);
+ };
+
+ return {
+ formState,
+ handleSubmit,
+ methods,
+ onFormSubmitHandler,
+ watch,
+ };
+};
diff --git a/frontend/src/entity/incomes/item/ui/AddIncomeItemForm/index.ts b/frontend/src/entity/incomes/item/ui/AddIncomeItemForm/index.ts
new file mode 100644
index 0000000..3abbcc1
--- /dev/null
+++ b/frontend/src/entity/incomes/item/ui/AddIncomeItemForm/index.ts
@@ -0,0 +1,5 @@
+export type { AddIncomeItemFormValues } from './AddIncomeItemForm.schema';
+export { AddIncomeItemFormSchema } from './AddIncomeItemForm.schema';
+
+export type { AddIncomeItemFormProps } from './AddIncomeItemForm.ui';
+export { default as AddIncomeItemForm } from './AddIncomeItemForm.ui';
diff --git a/frontend/src/entity/incomes/item/ui/DeleteIncomeItemForm/DeleteIncomeItemForm.test.tsx b/frontend/src/entity/incomes/item/ui/DeleteIncomeItemForm/DeleteIncomeItemForm.test.tsx
index 9fb49e4..3c303fc 100644
--- a/frontend/src/entity/incomes/item/ui/DeleteIncomeItemForm/DeleteIncomeItemForm.test.tsx
+++ b/frontend/src/entity/incomes/item/ui/DeleteIncomeItemForm/DeleteIncomeItemForm.test.tsx
@@ -13,6 +13,6 @@ describe('Test DeleteIncomeItemForm', () => {
await DeleteIncomeItemFormHelper.submitForm();
expect(onSubmit).toHaveBeenCalledTimes(1);
- expect(onSubmit).toHaveBeenCalledWith(DeleteIncomeItemFormHelper.incomeItem);
+ expect(onSubmit).toHaveBeenCalledWith();
});
});
diff --git a/frontend/src/entity/incomes/item/ui/DeleteIncomeItemForm/DeleteIncomeItemForm.ui.tsx b/frontend/src/entity/incomes/item/ui/DeleteIncomeItemForm/DeleteIncomeItemForm.ui.tsx
index cb6f1e3..c122a5a 100644
--- a/frontend/src/entity/incomes/item/ui/DeleteIncomeItemForm/DeleteIncomeItemForm.ui.tsx
+++ b/frontend/src/entity/incomes/item/ui/DeleteIncomeItemForm/DeleteIncomeItemForm.ui.tsx
@@ -1,6 +1,5 @@
import type {
FC,
- FormEventHandler,
FormHTMLAttributes,
RefObject,
} from 'react';
@@ -9,12 +8,11 @@ import { Button, Typography } from '@mui/material';
import { DataTestId } from '@/shared/constants';
-import type { IncomeListItem } from '../../types';
+import type { UseDeleteIncomeItemFormArgs } from './hooks/useDeleteIncomeItemForm';
-export type DeleteIncomeItemFormProps = BaseFormProps & {
- disabled?: boolean;
- item: IncomeListItem;
- onSubmit?: (item: IncomeListItem) => Promise | void;
+import { useDeleteIncomeItemForm } from './hooks/useDeleteIncomeItemForm';
+
+export type DeleteIncomeItemFormProps = BaseFormProps & UseDeleteIncomeItemFormArgs & {
ref?: RefObject;
};
@@ -23,20 +21,17 @@ type BaseFormProps = Omit, 'onSubmit'>;
const DeleteIncomeItemForm: FC = (props) => {
const {
disabled,
- item,
onSubmit,
ref,
...restProps
} = props;
- const onFormSubmitHandler: FormEventHandler = (event) => {
- event.preventDefault();
- onSubmit?.(item);
- };
+
+ const { formState, handleSubmit, onFormSubmitHandler } = useDeleteIncomeItemForm({ disabled, onSubmit });
return (
+
);
};
diff --git a/frontend/src/entity/incomes/item/ui/EditIncomeItemForm/fields/AmountField.tsx b/frontend/src/entity/incomes/item/ui/EditIncomeItemForm/fields/AmountField.tsx
new file mode 100644
index 0000000..b5f4a1a
--- /dev/null
+++ b/frontend/src/entity/incomes/item/ui/EditIncomeItemForm/fields/AmountField.tsx
@@ -0,0 +1,33 @@
+import { TextField } from '@mui/material';
+import { Controller, useFormContext } from 'react-hook-form';
+
+import type { EditIncomeItemFormValues } from '../EditIncomeItemForm.schema';
+
+const AmountField = () => {
+ const { control, formState } = useFormContext>();
+
+ return (
+ (
+ {
+ field.onChange(Number(event.target.value));
+ }}
+ required
+ size="small"
+ type="number"
+ />
+ )}
+ />
+ );
+};
+
+export default AmountField;
diff --git a/frontend/src/entity/incomes/item/ui/EditIncomeItemForm/fields/BankCardField.tsx b/frontend/src/entity/incomes/item/ui/EditIncomeItemForm/fields/BankCardField.tsx
new file mode 100644
index 0000000..903a571
--- /dev/null
+++ b/frontend/src/entity/incomes/item/ui/EditIncomeItemForm/fields/BankCardField.tsx
@@ -0,0 +1,58 @@
+import {
+ FormControl,
+ FormHelperText,
+ InputLabel,
+ MenuItem,
+ Select,
+} from '@mui/material';
+import { Controller, useFormContext } from 'react-hook-form';
+
+import { useBankCards } from '@/entity/cards';
+import { formatRub } from '@/shared/utils';
+
+import type { EditIncomeItemFormValues } from '../EditIncomeItemForm.schema';
+
+const BankCardField = () => {
+ const { control, formState } = useFormContext>();
+
+ const { data: bankCards } = useBankCards();
+
+ const bankCardOptions = bankCards?.map((card) => (
+
+ ));
+
+ return (
+ (
+
+ Карта
+
+
+
+
+ {formState.errors['bank-card']?.message}
+
+
+ )}
+ />
+ );
+};
+
+export default BankCardField;
diff --git a/frontend/src/entity/incomes/item/ui/EditIncomeItemForm/fields/CashField.tsx b/frontend/src/entity/incomes/item/ui/EditIncomeItemForm/fields/CashField.tsx
new file mode 100644
index 0000000..6caebc9
--- /dev/null
+++ b/frontend/src/entity/incomes/item/ui/EditIncomeItemForm/fields/CashField.tsx
@@ -0,0 +1,57 @@
+import {
+ FormControl,
+ FormHelperText,
+ InputLabel,
+ MenuItem,
+ Select,
+} from '@mui/material';
+import { Controller, useFormContext } from 'react-hook-form';
+
+import { useCash } from '@/entity/cash';
+import { formatRub } from '@/shared/utils';
+
+import type { EditIncomeItemFormValues } from '../EditIncomeItemForm.schema';
+
+const CashField = () => {
+ const { control, formState } = useFormContext>();
+ const { data: cash } = useCash();
+
+ const cashOptions = cash?.map((cashItem) => (
+
+ ));
+
+ return (
+ (
+
+ Наличные
+
+
+
+
+ {formState.errors.cash?.message}
+
+
+ )}
+ />
+ );
+};
+
+export default CashField;
diff --git a/frontend/src/entity/incomes/item/ui/EditIncomeItemForm/fields/DateField.tsx b/frontend/src/entity/incomes/item/ui/EditIncomeItemForm/fields/DateField.tsx
new file mode 100644
index 0000000..8316907
--- /dev/null
+++ b/frontend/src/entity/incomes/item/ui/EditIncomeItemForm/fields/DateField.tsx
@@ -0,0 +1,37 @@
+import type { FC } from 'react';
+
+import { DatePicker } from '@mui/x-date-pickers';
+import { Controller, useFormContext } from 'react-hook-form';
+
+import type { EditIncomeItemFormValues } from '../EditIncomeItemForm.schema';
+
+const DateField: FC = () => {
+ const { control, formState } = useFormContext>();
+
+ return (
+ (
+
+ )}
+ />
+ );
+};
+
+export default DateField;
diff --git a/frontend/src/entity/incomes/item/ui/EditIncomeItemForm/fields/DescriptionField.tsx b/frontend/src/entity/incomes/item/ui/EditIncomeItemForm/fields/DescriptionField.tsx
new file mode 100644
index 0000000..b6af784
--- /dev/null
+++ b/frontend/src/entity/incomes/item/ui/EditIncomeItemForm/fields/DescriptionField.tsx
@@ -0,0 +1,29 @@
+import type { FC } from 'react';
+
+import { TextField } from '@mui/material';
+import { Controller, useFormContext } from 'react-hook-form';
+
+import type { EditIncomeItemFormValues } from '../EditIncomeItemForm.schema';
+
+const DescriptionField: FC = () => {
+ const { control, formState } = useFormContext>();
+
+ return (
+ (
+
+ )}
+ />
+ );
+};
+
+export default DescriptionField;
diff --git a/frontend/src/entity/incomes/item/ui/EditIncomeItemForm/fields/TargetTypeField.tsx b/frontend/src/entity/incomes/item/ui/EditIncomeItemForm/fields/TargetTypeField.tsx
new file mode 100644
index 0000000..2a7ec51
--- /dev/null
+++ b/frontend/src/entity/incomes/item/ui/EditIncomeItemForm/fields/TargetTypeField.tsx
@@ -0,0 +1,69 @@
+import type { FC } from 'react';
+
+import {
+ FormControl,
+ FormHelperText,
+ InputLabel,
+ MenuItem,
+ Select,
+} from '@mui/material';
+import { useMemo } from 'react';
+import { Controller, useFormContext } from 'react-hook-form';
+
+import { useBankCards } from '@/entity/cards';
+import { useCash } from '@/entity/cash';
+
+import type { EditIncomeItemFormValues } from '../EditIncomeItemForm.schema';
+
+const TargetTypeField: FC = () => {
+ const { control, formState } = useFormContext>();
+
+ const { data: bankCards } = useBankCards();
+ const { data: cash } = useCash();
+
+ const targetTypeOptions = useMemo(() => {
+ const options = [];
+
+ if (bankCards && bankCards.length > 0) {
+ options.push();
+ }
+
+ if (cash && cash.length > 0) {
+ options.push();
+ }
+
+ return options;
+ }, [bankCards, cash]);
+
+ return (
+ (
+
+ Способ получения
+
+
+
+
+ {formState.errors.targetType?.message}
+
+
+ )}
+ />
+ );
+};
+
+export default TargetTypeField;
diff --git a/frontend/src/entity/incomes/item/ui/EditIncomeItemForm/fields/TitleField.tsx b/frontend/src/entity/incomes/item/ui/EditIncomeItemForm/fields/TitleField.tsx
new file mode 100644
index 0000000..b9398a7
--- /dev/null
+++ b/frontend/src/entity/incomes/item/ui/EditIncomeItemForm/fields/TitleField.tsx
@@ -0,0 +1,30 @@
+import type { FC } from 'react';
+
+import { TextField } from '@mui/material';
+import { Controller, useFormContext } from 'react-hook-form';
+
+import type { EditIncomeItemFormValues } from '../EditIncomeItemForm.schema';
+
+const TitleField: FC = () => {
+ const { control, formState } = useFormContext>();
+
+ return (
+ (
+
+ )}
+ />
+ );
+};
+
+export default TitleField;
diff --git a/frontend/src/entity/incomes/item/ui/EditIncomeItemForm/hooks/useEditIncomeItemForm.ts b/frontend/src/entity/incomes/item/ui/EditIncomeItemForm/hooks/useEditIncomeItemForm.ts
new file mode 100644
index 0000000..07dc1fc
--- /dev/null
+++ b/frontend/src/entity/incomes/item/ui/EditIncomeItemForm/hooks/useEditIncomeItemForm.ts
@@ -0,0 +1,54 @@
+import type { SubmitHandler } from 'react-hook-form';
+
+import { zodResolver } from '@hookform/resolvers/zod';
+import { useForm } from 'react-hook-form';
+
+import type { IncomeListItem } from '@/entity/incomes/item';
+
+import type { EditIncomeItemFormValues } from '../EditIncomeItemForm.schema';
+
+import { EditIncomeItemFormSchema } from '../EditIncomeItemForm.schema';
+
+export type UseEditIncomeItemFormArgs = {
+ disabled?: boolean;
+ item: IncomeListItem;
+ onSubmit: (data: EditIncomeItemFormValues) => Promise | void;
+};
+
+export const useEditIncomeItemForm = (args: UseEditIncomeItemFormArgs) => {
+ const { disabled, item, onSubmit } = args;
+
+ const methods = useForm({
+ defaultValues: {
+ amount: item.amount,
+ // eslint-disable-next-line no-undefined
+ 'bank-card': item.target?.type === 'BANK_CARD' ? item.target.id : undefined,
+ // eslint-disable-next-line no-undefined
+ cash: item.target?.type === 'CASH' ? item.target.id : undefined,
+ date: new Date(item.date),
+ description: item.description ?? '',
+ targetType: item.target?.type ?? 'none',
+ title: item.title,
+ },
+ disabled,
+ resolver: zodResolver(EditIncomeItemFormSchema),
+ shouldUnregister: true,
+ });
+ const {
+ formState,
+ handleSubmit,
+ watch,
+ } = methods;
+
+ const onFormSubmitHandler: SubmitHandler = async (data) => {
+ await onSubmit(data);
+ };
+
+ return {
+ formState,
+ handleSubmit,
+ methods,
+ onFormSubmitHandler,
+ watch,
+ };
+};
diff --git a/frontend/src/entity/incomes/item/ui/EditIncomeItemForm/index.ts b/frontend/src/entity/incomes/item/ui/EditIncomeItemForm/index.ts
new file mode 100644
index 0000000..4db454c
--- /dev/null
+++ b/frontend/src/entity/incomes/item/ui/EditIncomeItemForm/index.ts
@@ -0,0 +1,4 @@
+export type { EditIncomeItemFormValues } from './EditIncomeItemForm.schema';
+export { EditIncomeItemFormSchema } from './EditIncomeItemForm.schema';
+export type { EditIncomeItemFormProps } from './EditIncomeItemForm.ui';
+export { default as EditIncomeItemForm } from './EditIncomeItemForm.ui';
diff --git a/frontend/src/features/incomes/items/AddIncomeItemButton/hooks/useAddIncomeItemButton.ts b/frontend/src/features/incomes/items/AddIncomeItemButton/hooks/useAddIncomeItemButton.ts
index 53367d8..1c10a80 100644
--- a/frontend/src/features/incomes/items/AddIncomeItemButton/hooks/useAddIncomeItemButton.ts
+++ b/frontend/src/features/incomes/items/AddIncomeItemButton/hooks/useAddIncomeItemButton.ts
@@ -1,5 +1,6 @@
import type { ButtonHTMLAttributes, MouseEventHandler } from 'react';
+import { enqueueSnackbar } from 'notistack';
import { useState } from 'react';
import type { AddIncomeItemFormValues } from '@/entity/incomes/item';
@@ -26,10 +27,29 @@ export const useAddIncomeItemButton = (args: UseAddIncomeItemButtonArgs) => {
};
const onFormSubmitHandler = async (data: AddIncomeItemFormValues) => {
- // todo добавить обработку ошибок (код + тест)
- await addIncomeItemMutation(data);
+ try {
+ const target = data.targetType === 'none'
+ ? null
+ : {
+ id: data.targetType === 'BANK_CARD' ? data['bank-card'] : data.cash,
+ type: data.targetType,
+ };
+ await addIncomeItemMutation({
+ amount: data.amount,
+ currency: 'RUB',
+ date: data.date.toISOString(),
+ description: data.description ?? null,
+ // @ts-ignore TODO
+ target,
+ title: data.title,
+ });
- setIsOpen(false);
+ enqueueSnackbar(`Доход "${data.title}" успешно добавлен`, { variant: 'success' });
+
+ setIsOpen(false);
+ } catch {
+ enqueueSnackbar('Ошибка при добавлении дохода', { variant: 'error' });
+ }
};
return {
diff --git a/frontend/src/features/incomes/items/AddIncomeItemButton/ui/AddIncomeItemButton.test.tsx b/frontend/src/features/incomes/items/AddIncomeItemButton/ui/AddIncomeItemButton.test.tsx
index 0ee21ef..b303a63 100644
--- a/frontend/src/features/incomes/items/AddIncomeItemButton/ui/AddIncomeItemButton.test.tsx
+++ b/frontend/src/features/incomes/items/AddIncomeItemButton/ui/AddIncomeItemButton.test.tsx
@@ -1,3 +1,4 @@
+import { mockEnqueueSnackbar } from '@mocks/jest/helpers';
import { server } from '@mocks/jest/server';
import { screen, waitFor } from '@testing-library/react';
import { AddIncomeItemButtonHelper, AddIncomeItemFormHelper } from '@tests/helpers';
@@ -22,7 +23,7 @@ describe('Test AddIncomeItemButton', () => {
expect(modalElem).toBeVisible();
});
- describe('should close modal after submit and send request', () => {
+ describe('should close modal after success request', () => {
let modalElem: HTMLElement;
const requestSpy = jest.fn();
@@ -35,26 +36,56 @@ describe('Test AddIncomeItemButton', () => {
date: '29.08.2025',
description: 'Описание элемента',
title: 'Название элемента',
- }, { autoSubmit: false });
+ });
});
- test('should send correct request', async () => {
- await AddIncomeItemFormHelper.submitForm();
+ test('should send correct request', () => {
expect(requestSpy).toHaveBeenCalledTimes(1);
expect(requestSpy).toHaveBeenCalledWith({
amount: 12,
currency: 'RUB',
date: '2025-08-29T00:00:00.000Z',
description: 'Описание элемента',
+ target: null,
title: 'Название элемента',
});
});
test('should close modal after submit', async () => {
- await AddIncomeItemFormHelper.submitForm();
await waitFor(() => {
expect(modalElem).not.toBeInTheDocument();
});
});
+
+ test('should show success notification', () => {
+ expect(mockEnqueueSnackbar).toHaveBeenCalledTimes(1);
+ expect(mockEnqueueSnackbar).toHaveBeenCalledWith('Доход "Название элемента" успешно добавлен', { variant: 'success' });
+ });
+ });
+
+ describe('should not close modal after failed request', () => {
+ let modalElem: HTMLElement;
+ const requestSpy = jest.fn();
+
+ beforeEach(async () => {
+ server.use(AddIncomeItemButtonHelper.submitHandler(requestSpy, 500));
+ modalElem = (await AddIncomeItemButtonHelper.openModal()).modalElem;
+
+ await AddIncomeItemFormHelper.fillForm({
+ amount: 12,
+ date: '29.08.2025',
+ description: 'Описание элемента',
+ title: 'Название элемента',
+ });
+ });
+
+ test('should not close modal after submit', () => {
+ expect(modalElem).toBeInTheDocument();
+ });
+
+ test('should show error notification', () => {
+ expect(mockEnqueueSnackbar).toHaveBeenCalledTimes(1);
+ expect(mockEnqueueSnackbar).toHaveBeenCalledWith('Ошибка при добавлении дохода', { variant: 'error' });
+ });
});
});
diff --git a/frontend/src/features/incomes/items/AddIncomeItemButton/ui/AddIncomeItemButton.ui.tsx b/frontend/src/features/incomes/items/AddIncomeItemButton/ui/AddIncomeItemButton.ui.tsx
index e1e19f7..06bc3a8 100644
--- a/frontend/src/features/incomes/items/AddIncomeItemButton/ui/AddIncomeItemButton.ui.tsx
+++ b/frontend/src/features/incomes/items/AddIncomeItemButton/ui/AddIncomeItemButton.ui.tsx
@@ -44,7 +44,7 @@ const AddIncomeItemButton: FC = (props) => {
Добавить
-
+
>
diff --git a/frontend/src/features/incomes/items/DeleteIncomeItemButton/hooks/useDeleteIncomeItemButton.ts b/frontend/src/features/incomes/items/DeleteIncomeItemButton/hooks/useDeleteIncomeItemButton.ts
index 530f3bf..63a5914 100644
--- a/frontend/src/features/incomes/items/DeleteIncomeItemButton/hooks/useDeleteIncomeItemButton.ts
+++ b/frontend/src/features/incomes/items/DeleteIncomeItemButton/hooks/useDeleteIncomeItemButton.ts
@@ -1,5 +1,6 @@
import type { MouseEventHandler } from 'react';
+import { enqueueSnackbar } from 'notistack';
import { useState } from 'react';
import { type IncomeListItem, useDeleteIncomeItem } from '@/entity/incomes/item';
@@ -27,8 +28,14 @@ export const useDeleteIncomeItemButton = (args: UseDeleteIncomeItemButtonArgs) =
});
const onFormSubmitHandler = async () => {
- await deleteIncomeItemMutation();
- setIsOpen(false);
+ try {
+ await deleteIncomeItemMutation();
+
+ enqueueSnackbar(`Доход "${item.title}" удален`, { variant: 'success' });
+ setIsOpen(false);
+ } catch {
+ enqueueSnackbar(`Ошибка при удалении дохода "${item.title}"`, { variant: 'error' });
+ }
};
return {
diff --git a/frontend/src/features/incomes/items/DeleteIncomeItemButton/ui/DeleteIncomeItemButton.test.tsx b/frontend/src/features/incomes/items/DeleteIncomeItemButton/ui/DeleteIncomeItemButton.test.tsx
index 6cee691..42007e7 100644
--- a/frontend/src/features/incomes/items/DeleteIncomeItemButton/ui/DeleteIncomeItemButton.test.tsx
+++ b/frontend/src/features/incomes/items/DeleteIncomeItemButton/ui/DeleteIncomeItemButton.test.tsx
@@ -1,6 +1,6 @@
+import { mockEnqueueSnackbar } from '@mocks/jest/helpers';
import { server } from '@mocks/jest/server';
import { screen, waitFor } from '@testing-library/react';
-import userEvent from '@testing-library/user-event';
import { DeleteIncomeItemButtonHelper, DeleteIncomeItemFormHelper } from '@tests/helpers';
describe('Test DeleteIncomeItemButton', () => {
@@ -23,15 +23,51 @@ describe('Test DeleteIncomeItemButton', () => {
expect(modalElem).toBeVisible();
});
- test('should close modal after submit', async () => {
- server.use(DeleteIncomeItemButtonHelper.submitHandler);
- const { modalElem } = await DeleteIncomeItemButtonHelper.openModal();
- const { submitButtonElem } = DeleteIncomeItemFormHelper.getFormElements();
+ describe('should close modal after success request', () => {
+ let modalElem: HTMLElement;
+ const requestSpy = jest.fn();
- expect(submitButtonElem).toBeEnabled();
- await userEvent.click(submitButtonElem);
- await waitFor(() => {
- expect(modalElem).not.toBeInTheDocument();
+ beforeEach(async () => {
+ server.use(DeleteIncomeItemButtonHelper.submitHandler(requestSpy));
+ modalElem = (await DeleteIncomeItemButtonHelper.openModal()).modalElem;
+
+ await DeleteIncomeItemFormHelper.submitForm();
+ });
+
+ test('should send correct request', () => {
+ expect(requestSpy).toHaveBeenCalledTimes(1);
+ });
+
+ test('should close modal after submit', async () => {
+ await waitFor(() => {
+ expect(modalElem).not.toBeInTheDocument();
+ });
+ });
+
+ test('should show success notification', () => {
+ expect(mockEnqueueSnackbar).toHaveBeenCalledTimes(1);
+ expect(mockEnqueueSnackbar).toHaveBeenCalledWith('Доход "Mock item" удален', { variant: 'success' });
+ });
+ });
+
+ describe('should not close modal after failed request', () => {
+ let modalElem: HTMLElement;
+ const requestSpy = jest.fn();
+
+ beforeEach(async () => {
+ server.use(DeleteIncomeItemButtonHelper.submitHandler(requestSpy, 500));
+ modalElem = (await DeleteIncomeItemButtonHelper.openModal()).modalElem;
+
+ await DeleteIncomeItemFormHelper.submitForm();
+ });
+
+ test('should not close modal after submit', () => {
+ expect(modalElem).toBeInTheDocument();
+ });
+
+ test('should show error notification', () => {
+ expect(mockEnqueueSnackbar).toHaveBeenCalledTimes(1);
+ expect(mockEnqueueSnackbar).toHaveBeenCalledWith('Ошибка при удалении дохода "Mock item"', { variant: 'error' });
});
});
});
diff --git a/frontend/src/features/incomes/items/DeleteIncomeItemButton/ui/DeleteIncomeItemButton.ui.tsx b/frontend/src/features/incomes/items/DeleteIncomeItemButton/ui/DeleteIncomeItemButton.ui.tsx
index 5d43696..a31f8f0 100644
--- a/frontend/src/features/incomes/items/DeleteIncomeItemButton/ui/DeleteIncomeItemButton.ui.tsx
+++ b/frontend/src/features/incomes/items/DeleteIncomeItemButton/ui/DeleteIncomeItemButton.ui.tsx
@@ -44,7 +44,7 @@ const DeleteIncomeItemButton: FC = (props) => {
-
+
>
);
diff --git a/frontend/src/features/incomes/items/EditIncomeItemButton/hooks/useIncomeItemButton.ts b/frontend/src/features/incomes/items/EditIncomeItemButton/hooks/useIncomeItemButton.ts
index fd49c78..f96eade 100644
--- a/frontend/src/features/incomes/items/EditIncomeItemButton/hooks/useIncomeItemButton.ts
+++ b/frontend/src/features/incomes/items/EditIncomeItemButton/hooks/useIncomeItemButton.ts
@@ -1,5 +1,6 @@
import type { MouseEventHandler } from 'react';
+import { enqueueSnackbar } from 'notistack';
import { useState } from 'react';
import { type EditIncomeItemFormValues, type IncomeListItem, useEditIncomeItem } from '@/entity/incomes/item';
@@ -27,9 +28,29 @@ export const useIncomeItemButton = (args: UseAddIncomeItemButtonArgs) => {
};
const onFormSubmitHandler = async (data: EditIncomeItemFormValues) => {
- // todo добавить обработку ошибок (код + тест)
- await editIncomeItemMutation(data);
- setIsOpen(false);
+ try {
+ const target = data.targetType === 'none'
+ ? null
+ : {
+ id: data.targetType === 'BANK_CARD' ? data['bank-card'] : data.cash,
+ type: data.targetType,
+ };
+ await editIncomeItemMutation({
+ amount: data.amount,
+ currency: 'RUB',
+ date: data.date.toISOString(),
+ description: data.description ?? null,
+ // @ts-ignore TODO
+ target,
+ title: data.title,
+ });
+
+ enqueueSnackbar(`Доход "${data.title}" успешно сохранен`, { variant: 'success' });
+
+ setIsOpen(false);
+ } catch {
+ enqueueSnackbar('Ошибка при сохранении дохода', { variant: 'error' });
+ }
};
return {
diff --git a/frontend/src/features/incomes/items/EditIncomeItemButton/ui/EditIncomeItemButton.test.tsx b/frontend/src/features/incomes/items/EditIncomeItemButton/ui/EditIncomeItemButton.test.tsx
index 5761e54..c999593 100644
--- a/frontend/src/features/incomes/items/EditIncomeItemButton/ui/EditIncomeItemButton.test.tsx
+++ b/frontend/src/features/incomes/items/EditIncomeItemButton/ui/EditIncomeItemButton.test.tsx
@@ -1,3 +1,4 @@
+import { mockEnqueueSnackbar } from '@mocks/jest/helpers';
import { server } from '@mocks/jest/server';
import { screen, waitFor } from '@testing-library/react';
import { EditIncomeItemButtonHelper, EditIncomeItemFormHelper } from '@tests/helpers';
@@ -22,7 +23,7 @@ describe('TestEditIncomeItemButton', () => {
expect(modalElem).toBeVisible();
});
- describe('should close modal after submit and send request', () => {
+ describe('should close modal after success request', () => {
let modalElem: HTMLElement;
const requestSpy = jest.fn();
@@ -35,26 +36,56 @@ describe('TestEditIncomeItemButton', () => {
date: '29.08.2025',
description: 'Описание элемента',
title: 'Название элемента',
- }, { autoSubmit: false });
+ });
});
- test('should send correct request', async () => {
- await EditIncomeItemFormHelper.submitForm();
+ test('should send correct request', () => {
expect(requestSpy).toHaveBeenCalledTimes(1);
expect(requestSpy).toHaveBeenCalledWith({
amount: 12,
currency: 'RUB',
date: '2025-08-29T00:00:00.000Z',
description: 'Описание элемента',
+ target: null,
title: 'Название элемента',
});
});
test('should close modal after submit', async () => {
- await EditIncomeItemFormHelper.submitForm();
await waitFor(() => {
expect(modalElem).not.toBeInTheDocument();
});
});
+
+ test('should show success notification', () => {
+ expect(mockEnqueueSnackbar).toHaveBeenCalledTimes(1);
+ expect(mockEnqueueSnackbar).toHaveBeenCalledWith('Доход "Название элемента" успешно сохранен', { variant: 'success' });
+ });
+ });
+
+ describe('should not close modal after failed request', () => {
+ let modalElem: HTMLElement;
+ const requestSpy = jest.fn();
+
+ beforeEach(async () => {
+ server.use(EditIncomeItemButtonHelper.submitHandler(requestSpy, 500));
+ modalElem = (await EditIncomeItemButtonHelper.openModal()).modalElem;
+
+ await EditIncomeItemFormHelper.fillForm({
+ amount: 12,
+ date: '29.08.2025',
+ description: 'Описание элемента',
+ title: 'Название элемента',
+ });
+ });
+
+ test('should not close modal after submit', () => {
+ expect(modalElem).toBeInTheDocument();
+ });
+
+ test('should show error notification', () => {
+ expect(mockEnqueueSnackbar).toHaveBeenCalledTimes(1);
+ expect(mockEnqueueSnackbar).toHaveBeenCalledWith('Ошибка при сохранении дохода', { variant: 'error' });
+ });
});
});
diff --git a/frontend/src/features/incomes/items/EditIncomeItemButton/ui/EditIncomeItemButton.ui.tsx b/frontend/src/features/incomes/items/EditIncomeItemButton/ui/EditIncomeItemButton.ui.tsx
index 7dcfb4f..d79ff34 100644
--- a/frontend/src/features/incomes/items/EditIncomeItemButton/ui/EditIncomeItemButton.ui.tsx
+++ b/frontend/src/features/incomes/items/EditIncomeItemButton/ui/EditIncomeItemButton.ui.tsx
@@ -45,7 +45,7 @@ const EditIncomeItemButton: FC = (props) => {
Редактировать
-
+
>
diff --git a/frontend/src/pages/Income/ui/IncomePage.ui.tsx b/frontend/src/pages/Income/ui/IncomePage.ui.tsx
index ed246a4..e368d83 100644
--- a/frontend/src/pages/Income/ui/IncomePage.ui.tsx
+++ b/frontend/src/pages/Income/ui/IncomePage.ui.tsx
@@ -32,7 +32,12 @@ const IncomePage = () => {
return (
- Списки расходов
+ Элементы дохода
+ {' '}
+
+ "
+ {incomeList?.title}
+ "
diff --git a/frontend/src/shared/api/schema.ts b/frontend/src/shared/api/schema.ts
index 56e7757..fd722a1 100644
--- a/frontend/src/shared/api/schema.ts
+++ b/frontend/src/shared/api/schema.ts
@@ -754,6 +754,19 @@ export interface components {
*/
title: string;
};
+ IncomeSourceDto: {
+ /**
+ * @description Источник дохода
+ * @example CASH
+ * @enum {string}
+ */
+ type: "BANK_CARD" | "CASH";
+ /**
+ * @description ID карты или наличных
+ * @example 123
+ */
+ id: string;
+ };
IncomeListItemDto: {
/** @description ID */
id: string;
@@ -786,6 +799,8 @@ export interface components {
* @example RUB
*/
currency: string | null;
+ /** @description Источник */
+ target: components["schemas"]["IncomeSourceDto"] | null;
/**
* Format: date-time
* @description Дата создания
@@ -854,6 +869,8 @@ export interface components {
* @example RUB
*/
currency: string | null;
+ /** @description Источник */
+ target: components["schemas"]["IncomeSourceDto"] | null;
};
EditIncomeListItemDto: {
/**
diff --git a/frontend/src/shared/utils/index.ts b/frontend/src/shared/utils/index.ts
index f2b914b..b50af57 100644
--- a/frontend/src/shared/utils/index.ts
+++ b/frontend/src/shared/utils/index.ts
@@ -6,3 +6,4 @@ export { formatRub } from './formatters/currency';
export { formatDate } from './formatters/date';
export { getUTCDate } from './helpers/getUTCDate';
+export { setupZod } from './zod/config';
diff --git a/frontend/src/shared/utils/zod/config.ts b/frontend/src/shared/utils/zod/config.ts
new file mode 100644
index 0000000..0d46f1b
--- /dev/null
+++ b/frontend/src/shared/utils/zod/config.ts
@@ -0,0 +1,27 @@
+import * as z from 'zod';
+
+export const setupZod = () => z.config({
+ customError: (issue) => {
+ if (issue.code === 'invalid_type') {
+ if (typeof issue.input === 'undefined') {
+ return {
+ message: 'Обязательно к заполнению',
+ };
+ }
+
+ return {
+ message: 'Неверный формат',
+ };
+ }
+
+ if (issue.code === 'too_small') {
+ return {
+ message: `Минимум ${issue.minimum} символов`,
+ };
+ }
+
+ return {
+ message: 'Ошибка',
+ };
+ },
+});
diff --git a/frontend/tests/helpers/income/item/AddIncomeItemButton.tsx b/frontend/tests/helpers/income/item/AddIncomeItemButton.tsx
index 72f255a..0354ed1 100644
--- a/frontend/tests/helpers/income/item/AddIncomeItemButton.tsx
+++ b/frontend/tests/helpers/income/item/AddIncomeItemButton.tsx
@@ -1,3 +1,5 @@
+import { getBankCardList, getCashList } from '@mocks/browser/handlers';
+import { server } from '@mocks/jest/server';
import { createAppWrapper } from '@mocks/jest/wrappers';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
@@ -9,6 +11,7 @@ import { AddIncomeItemButton } from '@/features/incomes/items/AddIncomeItemButto
import { DataTestId } from '@/shared/constants';
const renderButton = () => {
+ server.use(getBankCardList, getCashList);
render(
,
{ wrapper: createAppWrapper() },
@@ -31,7 +34,7 @@ const openModal = async () => {
};
};
-const submitHandler = (requestSpy: jest.Mock) => http.post<
+const submitHandler = (requestSpy: jest.Mock, status = 200) => http.post<
AddIncomeItemPath,
AddIncomeItemBody,
AddIncomeItemSuccessResponse
@@ -45,13 +48,14 @@ const submitHandler = (requestSpy: jest.Mock) => http.post<
amount: 12,
createdAt: '2025-08-29T07:57:40.503Z',
currency: 'RUB',
- date: '2025-08-22T00:00:03.000Z',
+ date: '2025-08-22T00:00:00.000Z',
description: 'Mock description',
id: '51a44dc0-597b-49b9-85bb-e0bcd42db3f6',
incomeListId: '10cc093c-3875-4d33-a40a-df526266e262',
+ target: null,
title: 'Mock item',
updatedAt: '2025-08-29T07:57:40.503Z',
- });
+ }, { status });
},
);
diff --git a/frontend/tests/helpers/income/item/AddIncomeItemForm.tsx b/frontend/tests/helpers/income/item/AddIncomeItemForm.tsx
index b6d3352..4f4e76c 100644
--- a/frontend/tests/helpers/income/item/AddIncomeItemForm.tsx
+++ b/frontend/tests/helpers/income/item/AddIncomeItemForm.tsx
@@ -1,4 +1,6 @@
-import { typeDateField, typeTextField } from '@mocks/jest/helpers';
+import { getBankCardList, getCashList } from '@mocks/browser/handlers';
+import { selectOption, typeDateField, typeTextField } from '@mocks/jest/helpers';
+import { server } from '@mocks/jest/server';
import { createAppWrapper } from '@mocks/jest/wrappers';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
@@ -9,8 +11,8 @@ import { AddIncomeItemForm } from '@/entity/incomes/item';
import { DataTestId } from '@/shared/constants';
const renderForm = (props?: Partial) => {
+ server.use(getBankCardList, getCashList);
const onSubmit = jest.fn();
-
render(
,
{ wrapper: createAppWrapper() },
@@ -29,12 +31,25 @@ const getFormElements = () => {
const amountInputElem = screen.getByLabelText(/Сумма/i);
const submitButtonElem = screen.getByTestId(DataTestId.ADD_INCOME_ITEM_SUBMIT_BUTTON);
+ const targetTypeSelect = screen.getByTestId('target-type-select');
+
+ const bankCardSelect = screen.queryByTestId('bank-card-select');
+ const bankCardSelectInput = screen.queryByTestId('bank-card-select-input');
+
+ const cashSelect = screen.queryByTestId('cash-select');
+ const cashSelectInput = screen.queryByTestId('cash-select-input');
+
return {
amountInputElem,
+ bankCardSelect,
+ bankCardSelectInput,
+ cashSelect,
+ cashSelectInput,
dateInputElem,
descriptionInputElem,
formElem,
submitButtonElem,
+ targetTypeSelect,
titleInputElem,
};
};
@@ -45,13 +60,16 @@ const submitForm = async () => {
};
const fillForm = async (
- data: Omit,
+ data: Partial & { date: string }>,
options: { autoSubmit?: boolean } = {},
) => {
const {
amount,
+ 'bank-card': bankCard,
+ cash,
date,
description,
+ targetType,
title,
} = data;
const { autoSubmit = true } = options;
@@ -59,13 +77,42 @@ const fillForm = async (
amountInputElem,
dateInputElem,
descriptionInputElem,
+ targetTypeSelect,
titleInputElem,
} = getFormElements();
await typeTextField(titleInputElem, title);
await typeTextField(descriptionInputElem, description);
await typeDateField(dateInputElem, date);
- await typeTextField(amountInputElem, amount.toString());
+ await typeTextField(amountInputElem, amount?.toString());
+
+ if (targetType && targetType !== 'none') {
+ switch (targetType) {
+ case 'BANK_CARD':
+ await selectOption(targetTypeSelect, 'BANK_CARD');
+ break;
+ case 'CASH':
+ await selectOption(targetTypeSelect, 'CASH');
+ break;
+ default:
+ break;
+ }
+
+ if (!bankCard && !cash) {
+ return;
+ }
+ const { bankCardSelect, cashSelect } = getFormElements();
+ switch (targetType) {
+ case 'BANK_CARD':
+ await selectOption(bankCardSelect, bankCard);
+ break;
+ case 'CASH':
+ await selectOption(cashSelect, cash);
+ break;
+ default:
+ break;
+ }
+ }
if (autoSubmit) {
await submitForm();
diff --git a/frontend/tests/helpers/income/item/DeleteIncomeItemButton.tsx b/frontend/tests/helpers/income/item/DeleteIncomeItemButton.tsx
index 42656e1..320e7d0 100644
--- a/frontend/tests/helpers/income/item/DeleteIncomeItemButton.tsx
+++ b/frontend/tests/helpers/income/item/DeleteIncomeItemButton.tsx
@@ -12,10 +12,11 @@ const iconmeItem: IncomeListItem = {
amount: 12,
createdAt: '2025-08-29T07:57:40.503Z',
currency: 'RUB',
- date: '2025-08-22T00:00:03.000Z',
+ date: '2025-08-22T00:00:00.000Z',
description: 'Mock description',
id: '51a44dc0-597b-49b9-85bb-e0bcd42db3f6',
incomeListId: '10cc093c-3875-4d33-a40a-df526266e262',
+ target: null,
title: 'Mock item',
updatedAt: '2025-08-29T07:57:40.503Z',
};
@@ -43,9 +44,13 @@ const openModal = async () => {
};
};
-const submitHandler = http.delete(
+const submitHandler = (requestSpy: jest.Mock, status = 200) => http.delete(
`/incomes/list/${iconmeItem.incomeListId}/items/${iconmeItem.id}`,
- () => HttpResponse.json(iconmeItem),
+ () => {
+ requestSpy(iconmeItem);
+
+ return HttpResponse.json(iconmeItem, { status });
+ },
);
export const DeleteIncomeItemButtonHelper = {
diff --git a/frontend/tests/helpers/income/item/DeleteIncomeItemForm.tsx b/frontend/tests/helpers/income/item/DeleteIncomeItemForm.tsx
index 7e68047..069b9cc 100644
--- a/frontend/tests/helpers/income/item/DeleteIncomeItemForm.tsx
+++ b/frontend/tests/helpers/income/item/DeleteIncomeItemForm.tsx
@@ -10,10 +10,11 @@ const incomeItem = {
amount: 12,
createdAt: '2025-08-29T07:57:40.503Z',
currency: 'RUB',
- date: '2025-08-22T00:00:03.000Z',
+ date: '2025-08-22T00:00:00.000Z',
description: 'Mock description',
id: '51a44dc0-597b-49b9-85bb-e0bcd42db3f6',
incomeListId: '10cc093c-3875-4d33-a40a-df526266e262',
+ target: null,
title: 'Mock item',
updatedAt: '2025-08-29T07:57:40.503Z',
};
@@ -23,7 +24,6 @@ const renderForm = (props?: Partial) => {
render(
,
diff --git a/frontend/tests/helpers/income/item/EditIncomeItemButton.tsx b/frontend/tests/helpers/income/item/EditIncomeItemButton.tsx
index d605caa..a358b09 100644
--- a/frontend/tests/helpers/income/item/EditIncomeItemButton.tsx
+++ b/frontend/tests/helpers/income/item/EditIncomeItemButton.tsx
@@ -1,3 +1,5 @@
+import { getBankCardList, getCashList } from '@mocks/browser/handlers';
+import { server } from '@mocks/jest/server';
import { createAppWrapper } from '@mocks/jest/wrappers';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
@@ -17,15 +19,17 @@ const incomeItem: IncomeListItem = {
amount: 12,
createdAt: '2025-08-29T07:57:40.503Z',
currency: 'RUB',
- date: '2025-08-22T00:00:03.000Z',
+ date: '2025-08-22T00:00:00.000Z',
description: 'Mock description',
id: '51a44dc0-597b-49b9-85bb-e0bcd42db3f6',
incomeListId: '10cc093c-3875-4d33-a40a-df526266e262',
+ target: null,
title: 'Mock item',
updatedAt: '2025-08-29T07:57:40.503Z',
};
const renderButton = () => {
+ server.use(getBankCardList, getCashList);
render(
,
{ wrapper: createAppWrapper() },
@@ -48,7 +52,7 @@ const openModal = async () => {
};
};
-const submitHandler = (requestSpy: jest.Mock) => http.patch<
+const submitHandler = (requestSpy: jest.Mock, status = 200) => http.patch<
EditIncomeItemPath,
EditIncomeItemBody,
EditIncomeItemSuccessResponse
@@ -58,7 +62,7 @@ const submitHandler = (requestSpy: jest.Mock) => http.patch<
const body = await request.json();
requestSpy(body);
- return HttpResponse.json(incomeItem);
+ return HttpResponse.json(incomeItem, { status });
},
);
diff --git a/frontend/tests/helpers/income/item/EditIncomeItemForm.tsx b/frontend/tests/helpers/income/item/EditIncomeItemForm.tsx
index dfe6fb8..ceff0d9 100644
--- a/frontend/tests/helpers/income/item/EditIncomeItemForm.tsx
+++ b/frontend/tests/helpers/income/item/EditIncomeItemForm.tsx
@@ -1,4 +1,6 @@
-import { typeDateField, typeTextField } from '@mocks/jest/helpers';
+import { getBankCardList, getCashList } from '@mocks/browser/handlers';
+import { selectOption, typeDateField, typeTextField } from '@mocks/jest/helpers';
+import { server } from '@mocks/jest/server';
import { createAppWrapper } from '@mocks/jest/wrappers';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
@@ -13,15 +15,17 @@ const incomeItem = {
count: 12,
createdAt: '2025-08-29T07:57:40.503Z',
currency: 'RUB',
- date: '2025-08-22T00:00:03.000Z',
+ date: '2025-08-22T00:00:00.000Z',
description: 'Mock description',
id: '51a44dc0-597b-49b9-85bb-e0bcd42db3f6',
incomeListId: '10cc093c-3875-4d33-a40a-df526266e262',
+ target: null,
title: 'Mock item',
updatedAt: '2025-08-29T07:57:40.503Z',
};
const renderForm = (props?: Partial) => {
+ server.use(getBankCardList, getCashList);
const onSubmit = jest.fn();
render(
@@ -45,13 +49,25 @@ const getFormElements = () => {
const dateInputElem = screen.getAllByLabelText(/Дата/i)[1];
const amountInputElem = screen.getByLabelText(/Сумма/i);
const submitButtonElem = screen.getByTestId(DataTestId.EDIT_INCOME_ITEM_SUBMIT_BUTTON);
+ const targetTypeSelect = screen.getByTestId('target-type-select');
+
+ const bankCardSelect = screen.queryByTestId('bank-card-select');
+ const bankCardSelectInput = screen.queryByTestId('bank-card-select-input');
+
+ const cashSelect = screen.queryByTestId('cash-select');
+ const cashSelectInput = screen.queryByTestId('cash-select-input');
return {
amountInputElem,
+ bankCardSelect,
+ bankCardSelectInput,
+ cashSelect,
+ cashSelectInput,
dateInputElem,
descriptionInputElem,
formElem,
submitButtonElem,
+ targetTypeSelect,
titleInputElem,
};
};
@@ -62,13 +78,16 @@ const submitForm = async () => {
};
const fillForm = async (
- data: Omit,
+ data: Partial & { date: string }>,
options: { autoSubmit?: boolean } = {},
) => {
const {
amount,
+ 'bank-card': bankCard,
+ cash,
date,
description,
+ targetType,
title,
} = data;
const { autoSubmit = true } = options;
@@ -76,13 +95,42 @@ const fillForm = async (
amountInputElem,
dateInputElem,
descriptionInputElem,
+ targetTypeSelect,
titleInputElem,
} = getFormElements();
await typeTextField(titleInputElem, title);
await typeTextField(descriptionInputElem, description);
await typeDateField(dateInputElem, date);
- await typeTextField(amountInputElem, amount.toString());
+ await typeTextField(amountInputElem, amount?.toString());
+
+ if (targetType && targetType !== 'none') {
+ switch (targetType) {
+ case 'BANK_CARD':
+ await selectOption(targetTypeSelect, 'BANK_CARD');
+ break;
+ case 'CASH':
+ await selectOption(targetTypeSelect, 'CASH');
+ break;
+ default:
+ break;
+ }
+
+ if (!bankCard && !cash) {
+ return;
+ }
+ const { bankCardSelect, cashSelect } = getFormElements();
+ switch (targetType) {
+ case 'BANK_CARD':
+ await selectOption(bankCardSelect, bankCard);
+ break;
+ case 'CASH':
+ await selectOption(cashSelect, cash);
+ break;
+ default:
+ break;
+ }
+ }
if (autoSubmit) {
await submitForm();