feat(frontend): add expenses

This commit is contained in:
Sergey Krylov 2025-08-28 05:05:29 +03:00
parent d60aae26e6
commit 10c0e5cf7c
83 changed files with 1863 additions and 109 deletions

View File

@ -77,6 +77,11 @@ export default [
'@typescript-eslint/await-thenable': 'off',
'@typescript-eslint/no-misused-promises': 'off',
'@typescript-eslint/prefer-nullish-coalescing': 'off',
'@typescript-eslint/no-unnecessary-condition': 'off',
'jsx-a11y/click-events-have-key-events': 'off',
'jsx-a11y/no-static-element-interactions': 'off',
'@stylistic/indent': 'off',
'@stylistic/jsx-one-expression-per-line': 'off',
},
},
{

View File

@ -23,7 +23,7 @@
"@types/react": "19.1.9",
"@types/react-dom": "19.1.7",
"@typescript-eslint/parser": "8.39.0",
"eslint": "9.33.0",
"eslint": "9.34.0",
"eslint-config-ksv741": "0.2.0",
"openapi-typescript": "7.9.1",
"typescript": "5.9.2"
@ -203,9 +203,9 @@
}
},
"node_modules/@eslint/js": {
"version": "9.33.0",
"resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.33.0.tgz",
"integrity": "sha512-5K1/mKhWaMfreBGJTwval43JJmkip0RmM+3+IuqupeSKNC/Th2Kc7ucaq5ovTSra/OOKB9c58CGSz3QMVbWt0A==",
"version": "9.34.0",
"resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.34.0.tgz",
"integrity": "sha512-EoyvqQnBNsV1CWaEJ559rxXL4c8V92gxirbawSmVUOWXlsRxxQXl6LmCpdUblgxgSkDIqKnhzba2SjRTI/A5Rw==",
"dev": true,
"license": "MIT",
"engines": {
@ -2666,9 +2666,9 @@
}
},
"node_modules/eslint": {
"version": "9.33.0",
"resolved": "https://registry.npmjs.org/eslint/-/eslint-9.33.0.tgz",
"integrity": "sha512-TS9bTNIryDzStCpJN93aC5VRSW3uTx9sClUn4B87pwiCaJh220otoI0X8mJKr+VcPtniMdN8GKjlwgWGUv5ZKA==",
"version": "9.34.0",
"resolved": "https://registry.npmjs.org/eslint/-/eslint-9.34.0.tgz",
"integrity": "sha512-RNCHRX5EwdrESy3Jc9o8ie8Bog+PeYvvSR8sDGoZxNFTvZ4dlxUB3WzQ3bQMztFrSRODGrLLj8g6OFuGY/aiQg==",
"dev": true,
"license": "MIT",
"dependencies": {
@ -2678,7 +2678,7 @@
"@eslint/config-helpers": "^0.3.1",
"@eslint/core": "^0.15.2",
"@eslint/eslintrc": "^3.3.1",
"@eslint/js": "9.33.0",
"@eslint/js": "9.34.0",
"@eslint/plugin-kit": "^0.3.5",
"@humanfs/node": "^0.16.6",
"@humanwhocodes/module-importer": "^1.0.1",

View File

@ -27,7 +27,7 @@
"@types/react": "19.1.9",
"@types/react-dom": "19.1.7",
"@typescript-eslint/parser": "8.39.0",
"eslint": "9.33.0",
"eslint": "9.34.0",
"eslint-config-ksv741": "0.2.0",
"openapi-typescript": "7.9.1",
"typescript": "5.9.2"

View File

@ -7,8 +7,11 @@ import {
} from 'react-router';
import { LoginPage, RegisterPage } from '@/pages/Authorization';
import { ExpensePage } from '@/pages/Expense';
import { ExpensesPage } from '@/pages/Expenses';
import { HomePage } from '@/pages/Home';
import { AuthContextProvider } from '@/shared/context/AuthContext';
import { PAGES } from '@/shared/router';
import { AuthRoute } from '@/shared/ui/AuthRoute';
const queryClient = new QueryClient();
@ -19,13 +22,17 @@ const App = () => (
<BrowserRouter>
<Routes>
<Route element={<AuthRoute />}>
<Route element={<HomePage />} path="/" />
<Route element={<HomePage />} path={PAGES.Home} />
<Route element={<ExpensesPage />} path={PAGES.Expenses} />
<Route element={<ExpensePage />} path={PAGES.Expense} />
</Route>
<Route element={<AuthRoute needAuth={false} to="/" />}>
<Route element={<LoginPage />} path="/login" />
<Route element={<LoginPage />} path={PAGES.Login} />
<Route element={<RegisterPage />} path="/register" />
<Route element={<RegisterPage />} path={PAGES.Register} />
</Route>
</Routes>
</BrowserRouter>

View File

@ -347,3 +347,7 @@ template {
[hidden] {
display: none;
}
* {
box-sizing: border-box;
}

View File

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

View File

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

View File

@ -1,5 +1,3 @@
import { useMutation } from '@tanstack/react-query';
import { api } from '@/shared/api/client';
import type { paths } from '@/shared/api/schema';
@ -7,16 +5,8 @@ import type { paths } from '@/shared/api/schema';
type RegisterRequestBody = paths['/auth/register']['post']['requestBody']['content']['application/json'];
type RegisterSuccessResponse = paths['/auth/register']['post']['responses']['200']['content']['application/json'];
async function register(data: RegisterRequestBody) {
export async function register(data: RegisterRequestBody) {
const { data: result } = await api.post<RegisterSuccessResponse>('auth/register', data);
return result;
}
export const useRegister = () => useMutation({
mutationFn: register,
onError: (error) => {
// eslint-disable-next-line no-console
console.log('Неуспешная регистрация', error);
},
});

View File

@ -2,19 +2,10 @@ import { useMutation } from '@tanstack/react-query';
import { useContext } from 'react';
import { useNavigate } from 'react-router';
import { api } from '@/shared/api/client';
import { AuthContext } from '@/shared/context/AuthContext';
import { PAGES } from '@/shared/router';
import type { paths } from '@/shared/api/schema';
type LoginRequestBody = paths['/auth/login']['post']['requestBody']['content']['application/json'];
type LoginSuccessResponse = paths['/auth/login']['post']['responses']['200']['content']['application/json'];
async function login(data: LoginRequestBody): Promise<LoginSuccessResponse> {
const { data: result } = await api.post<LoginSuccessResponse>('/auth/login', data);
return result;
}
import { login } from '../api/login';
export const useLogin = () => {
const navigate = useNavigate();
@ -25,7 +16,7 @@ export const useLogin = () => {
onSuccess: async (data) => {
setAccessToken(data.accessToken);
localStorage.setItem('accessToken', data.accessToken);
await navigate('/another');
await navigate(PAGES.Home);
},
onError: (error) => {
// eslint-disable-next-line

View File

@ -2,18 +2,10 @@ import { useMutation } from '@tanstack/react-query';
import { useContext } from 'react';
import { useNavigate } from 'react-router';
import { api } from '@/shared/api/client';
import { AuthContext } from '@/shared/context/AuthContext';
import { PAGES } from '@/shared/router';
import type { paths } from '@/shared/api/schema';
type LogoutSuccessResponse = paths['/auth/logout']['post']['responses']['200']['content']['application/json'];
async function logout() {
const { data: result } = await api.post<LogoutSuccessResponse>('/auth/logout');
return result;
}
import { logout } from '../api/logout';
export const useLogout = () => {
const navigate = useNavigate();
@ -24,7 +16,7 @@ export const useLogout = () => {
onSuccess: async () => {
setAccessToken('');
localStorage.removeItem('accessToken');
await navigate('/login');
await navigate(PAGES.Login);
},
});
};

View File

@ -0,0 +1,11 @@
import { useMutation } from '@tanstack/react-query';
import { register } from '../api/register';
export const useRegister = () => useMutation({
mutationFn: register,
onError: (error) => {
// eslint-disable-next-line no-console
console.log('Неуспешная регистрация', error);
},
});

View File

@ -1,3 +1,3 @@
export { useLogin } from './login/useLogin';
export { useRegister } from './register/useRegister';
export { useLogout } from './logout/useLogout';
export { useLogin } from './hooks/useLogin';
export { useRegister } from './hooks/useRegister';
export { useLogout } from './hooks/useLogout';

View File

@ -0,0 +1,13 @@
import { api } from '@/shared/api/client';
import type { paths } from '@/shared/api/schema';
export type AddExpenseItemBody = paths['/expenses/list/{id}/items']['post']['requestBody']['content']['application/json'];
export type AddExpenseItemPath = paths['/expenses/list/{id}/items']['post']['parameters']['path'];
export type AddExpenseItemSuccessResponse = paths['/expenses/list/{id}/items']['post']['responses']['200']['content']['application/json'];
export async function addExpenseItem(path: AddExpenseItemPath, data: AddExpenseItemBody) {
const { data: result } = await api.post<AddExpenseItemSuccessResponse>(`/expenses/list/${path.id}/items`, data);
return result;
}

View File

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

View File

@ -0,0 +1,13 @@
import { api } from '@/shared/api/client';
import type { paths } from '@/shared/api/schema';
export type EditExpenseItemBody = paths['/expenses/list/{listId}/items/{id}']['patch']['requestBody']['content']['application/json'];
export type EditExpenseItemPath = paths['/expenses/list/{listId}/items/{id}']['patch']['parameters']['path'];
export type EditExpenseItemSuccessResponse = paths['/expenses/list/{listId}/items/{id}']['patch']['responses']['200']['content']['application/json'];
export async function editExpenseItem(path: EditExpenseItemPath, data: EditExpenseItemBody) {
const { data: result } = await api.patch<EditExpenseItemSuccessResponse>(`/expenses/list/${path.listId}/items/${path.id}`, data);
return result;
}

View File

@ -0,0 +1,15 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { addExpenseItem, type AddExpenseItemBody, type AddExpenseItemPath } from '../api/addExpenseItem';
export const useAddExpenseItem = (path: AddExpenseItemPath) => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (data: AddExpenseItemBody) => addExpenseItem(path, data),
mutationKey: ['expenseList', path.id],
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: ['expenseList', path.id] });
},
});
};

View File

@ -0,0 +1,17 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { deleteExpenseItem } from '../api/deleteExpenseItem';
import type { DeleteExpenseItemPath } from '../api/deleteExpenseItem';
export const useDeleteExpenseItem = (path: DeleteExpenseItemPath) => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async () => deleteExpenseItem(path),
mutationKey: ['expenseList', path.listId],
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: ['expenseList', path.listId] });
},
});
};

View File

@ -0,0 +1,15 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { editExpenseItem, type EditExpenseItemBody, type EditExpenseItemPath } from '../api/editExpenseItem';
export const useEditExpenseItem = (path: EditExpenseItemPath) => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (data: EditExpenseItemBody) => editExpenseItem(path, data),
mutationKey: ['expenseList', path.listId],
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: ['expenseList', path.listId] });
},
});
};

View File

@ -0,0 +1,5 @@
export { useAddExpenseItem } from './hooks/useAddExpenseItem';
export { useDeleteExpenseItem } from './hooks/useDeleteExpenseItem';
export { useEditExpenseItem } from './hooks/useEditExpenseItem';
export type { ExpenseListItem } from './types';

View File

@ -0,0 +1,3 @@
import type { paths } from '@/shared/api/schema';
export type ExpenseListItem = NonNullable<paths['/expenses/list/{id}']['get']['responses']['200']['content']['application/json']['items']>[number];

View File

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

View File

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

View File

@ -0,0 +1,17 @@
import { api } from '@/shared/api/client';
import type { paths } from '@/shared/api/schema';
type EditExpenseListPath = paths['/expenses/list/{id}']['patch']['parameters']['path'];
type EditExpenseListBody = paths['/expenses/list/{id}']['patch']['requestBody']['content']['application/json'];
type EditExpenseListSuccessResponse = paths['/expenses/list/{id}']['patch']['responses']['200']['content']['application/json'];
export async function editExpensesList({ data, path }: {
path: EditExpenseListPath;
data: EditExpenseListBody;
}) {
const { id } = path;
const { data: result } = await api.patch<EditExpenseListSuccessResponse>(`/expenses/list/${id}`, data);
return result;
}

View File

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

View File

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

View File

@ -0,0 +1,15 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { addExpensesList } from '../api/addExpensesList';
export const useAddExpensesList = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: addExpensesList,
mutationKey: ['expensesList'],
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: ['expensesList'] });
},
});
};

View File

@ -0,0 +1,15 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { deleteExpensesList } from '../api/deleteExpensesList';
export const useDeleteExpensesList = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: deleteExpensesList,
mutationKey: ['expensesList'],
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: ['expensesList'] });
},
});
};

View File

@ -0,0 +1,15 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { editExpensesList } from '../api/editExpensesList';
export const useEditExpensesList = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: editExpensesList,
mutationKey: ['expensesList'],
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: ['expensesList'] });
},
});
};

View File

@ -0,0 +1,10 @@
import { useQuery } from '@tanstack/react-query';
import { getExpenseList } from '../api/getExpenseList';
import type { ExpenseList } from '../types';
export const useExpenseList = (id: ExpenseList['id']) => useQuery({
queryFn: async () => getExpenseList({ id }),
queryKey: ['expenseList', id],
});

View File

@ -0,0 +1,8 @@
import { useQuery } from '@tanstack/react-query';
import { getExpenseLists } from '../api/getExpenseLists';
export const useExpensesList = () => useQuery({
queryFn: getExpenseLists,
queryKey: ['expensesList'],
});

View File

@ -0,0 +1,7 @@
export type { ExpenseList } from './types';
export { useDeleteExpensesList } from './hooks/useDeleteExpensesList';
export { useAddExpensesList } from './hooks/useAddExpensesList';
export { useExpensesList } from './hooks/useExpensesList';
export { useEditExpensesList } from './hooks/useEditExpensesList';
export { useExpenseList } from './hooks/useExpenseList';

View File

@ -0,0 +1,3 @@
import type { paths } from '@/shared/api/schema';
export type ExpenseList = paths['/expenses/list/{id}']['get']['responses']['200']['content']['application/json'];

View File

@ -0,0 +1,49 @@
import { type FormEventHandler, useState } from 'react';
import { useAddExpenseItem } from '@/entity/expenses/item';
import type { ExpenseList } from '@/entity/expenses/list';
type UseAddExpenseItemButtonArgs = {
listId: ExpenseList['id'];
};
export const useAddExpenseItemButton = (args: UseAddExpenseItemButtonArgs) => {
const { listId } = args;
const [isOpen, setIsOpen] = useState(false);
const { mutateAsync: addExpenseItemMutation, isPending } = useAddExpenseItem({ id: listId });
const onCloseModalHandler = () => setIsOpen(false);
const onClickHandler = () => setIsOpen(true);
const onFormSubmitHandler: FormEventHandler<HTMLFormElement> = async (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');
const count = formData.get('count');
if (typeof title === 'string' && typeof date === 'string' && typeof amount === 'string' && typeof description === 'string' && typeof count === 'string') {
await addExpenseItemMutation({
title,
date: new Date(date).toISOString(),
amount: Number(amount),
description,
currency: 'RUB',
count: Number(count),
});
setIsOpen(false);
}
};
return {
isOpen,
onFormSubmitHandler,
onCloseModalHandler,
isPending,
onClickHandler,
};
};

View File

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

View File

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

View File

@ -0,0 +1,80 @@
import { Input } from '@/shared/ui/Input';
import { Modal } from '@/shared/ui/Modal';
import { useAddExpenseItemButton } from '../hooks/useAddExpenseItemButton';
import classes from './AddExpenseItemButton.module.css';
import type { ExpenseList } from '@/entity/expenses/list';
import type { FC } from 'react';
type AddExpenseItemButtonProps = {
listId: ExpenseList['id'];
};
const AddExpenseItemButton: FC<AddExpenseItemButtonProps> = (props) => {
const { listId } = props;
const {
isOpen,
isPending,
onClickHandler,
onCloseModalHandler,
onFormSubmitHandler,
} = useAddExpenseItemButton({ listId });
return (
<>
<button type="button" onClick={onClickHandler}>
Добавить элемент
</button>
<Modal open={isOpen} onClose={onCloseModalHandler}>
<form className={classes.form} onSubmit={onFormSubmitHandler}>
<h2>Добавить список расходов</h2>
<Input
required
disabled={isPending}
label="Название"
name="title"
/>
<Input
disabled={isPending}
label="Описание"
name="description"
/>
<Input
required
disabled={isPending}
label="Дата"
name="date"
type="date"
/>
<Input
required
disabled={isPending}
label="Сумма"
name="amount"
type="number"
/>
<Input
disabled={isPending}
label="Количество"
name="count"
type="number"
/>
<button disabled={isPending} type="submit">Добавить</button>
</form>
</Modal>
</>
);
};
export default AddExpenseItemButton;

View File

@ -0,0 +1,35 @@
import { type FormEventHandler, useState } from 'react';
import { type ExpenseListItem, useDeleteExpenseItem } from '@/entity/expenses/item';
type UseDeleteExpenseItemButtonArgs = {
item: ExpenseListItem;
};
export const useDeleteExpenseItemButton = (args: UseDeleteExpenseItemButtonArgs) => {
const { item } = args;
const [isOpen, setIsOpen] = useState(false);
const onCloseModalHandler = () => setIsOpen(false);
const onClickHandler = () => setIsOpen(true);
const { mutateAsync: deleteExpenseItemMutation, isPending } = useDeleteExpenseItem({
listId: item.expenseListId,
id: item.id,
});
const onFormSubmitHandler: FormEventHandler<HTMLFormElement> = async (event) => {
event.preventDefault();
await deleteExpenseItemMutation();
setIsOpen(false);
};
return {
isOpen,
onCloseModalHandler,
onClickHandler,
isPending,
onFormSubmitHandler,
};
};

View File

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

View File

@ -0,0 +1,38 @@
import { Modal } from '@/shared/ui/Modal';
import { useDeleteExpenseItemButton } from '../hooks/useDeleteExpenseItemButton';
import type { ExpenseListItem } from '@/entity/expenses/item';
import type { FC } from 'react';
type DeleteExpenseItemButtonProps = {
item: ExpenseListItem;
};
const DeleteExpenseItemButton: FC<DeleteExpenseItemButtonProps> = (props) => {
const { item } = props;
const {
isOpen,
onCloseModalHandler,
onClickHandler,
isPending,
onFormSubmitHandler,
} = useDeleteExpenseItemButton({ item });
return (
<>
<button type="button" onClick={onClickHandler}>
Удалить
</button>
<Modal open={isOpen} onClose={onCloseModalHandler}>
<form onSubmit={onFormSubmitHandler}>
<h2>Удалить элемент ?</h2>
<button disabled={isPending} type="submit">Удалить</button>
</form>
</Modal>
</>
);
};
export default DeleteExpenseItemButton;

View File

@ -0,0 +1,50 @@
import { type FormEventHandler, useState } from 'react';
import { type ExpenseListItem, useEditExpenseItem } from '@/entity/expenses/item';
type UseAddExpenseItemButtonArgs = {
item: ExpenseListItem;
};
export const useAddExpenseItemButton = (args: UseAddExpenseItemButtonArgs) => {
const { item } = args;
const [isOpen, setIsOpen] = useState(false);
const { mutateAsync: editExpenseItemMutation, isPending } = useEditExpenseItem({
id: item.id,
listId: item.expenseListId,
});
const onCloseModalHandler = () => setIsOpen(false);
const onClickHandler = () => setIsOpen(true);
const onFormSubmitHandler: FormEventHandler<HTMLFormElement> = async (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');
const count = formData.get('count');
if (typeof title === 'string' && typeof date === 'string' && typeof amount === 'string' && typeof description === 'string' && typeof count === 'string') {
await editExpenseItemMutation({
title,
date: new Date(date).toISOString(),
amount: Number(amount),
description,
currency: 'RUB',
count: Number(count),
});
setIsOpen(false);
}
};
return {
isOpen,
onFormSubmitHandler,
onCloseModalHandler,
isPending,
onClickHandler,
};
};

View File

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

View File

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

View File

@ -0,0 +1,81 @@
import { Input } from '@/shared/ui/Input';
import { Modal } from '@/shared/ui/Modal';
import { useAddExpenseItemButton } from '../hooks/useEditExpenseItemButton';
import classes from './EditExpenseItemButton.module.css';
import type { ExpenseListItem } from '@/entity/expenses/item';
import type { FC } from 'react';
type EditExpenseItemButtonProps = {
item: ExpenseListItem;
};
const EditExpenseItemButton: FC<EditExpenseItemButtonProps> = (props) => {
const { item } = props;
const {
isOpen,
onCloseModalHandler,
onClickHandler,
isPending,
onFormSubmitHandler,
} = useAddExpenseItemButton({ item });
return (
<>
<button type="button" onClick={onClickHandler}>
Редактировать
</button>
<Modal open={isOpen} onClose={onCloseModalHandler}>
<form className={classes.form} onSubmit={onFormSubmitHandler}>
<h2>Добавить список расходов</h2>
<Input
defaultValue={item.title}
disabled={isPending}
label="Название"
name="title"
/>
<Input
defaultValue={item.description ?? ''}
disabled={isPending}
label="Описание"
name="description"
/>
<Input
defaultValue={new Date(item.date).toISOString().split('T')[0]}
disabled={isPending}
label="Дата"
name="date"
type="date"
/>
<Input
defaultValue={item.amount}
disabled={isPending}
label="Сумма"
name="amount"
type="number"
/>
<Input
defaultValue={item.count ?? 1}
disabled={isPending}
label="Количество"
name="count"
type="number"
/>
<button disabled={isPending} type="submit">Сохранить</button>
</form>
</Modal>
</>
);
};
export default EditExpenseItemButton;

View File

@ -0,0 +1,43 @@
import { type FormEventHandler, type MouseEventHandler, useState } from 'react';
import { useAddExpensesList } from '@/entity/expenses/list';
type UseAddExpensesListButtonArgs = {
onClick?: MouseEventHandler<HTMLButtonElement>;
};
export const useAddExpensesListButton = (args: UseAddExpensesListButtonArgs) => {
const { onClick } = args;
const [isOpen, setIsOpen] = useState(false);
const { mutateAsync: addExpensesListMutation, isPending } = useAddExpensesList();
const onCloseModalHandler = () => {
setIsOpen(false);
};
const onClickHandler: MouseEventHandler<HTMLButtonElement> = (event) => {
onClick?.(event);
setIsOpen(true);
};
const onFormSubmitHandler: FormEventHandler<HTMLFormElement> = async (event) => {
event.preventDefault();
const formData = new FormData(event.currentTarget);
const title = formData.get('title');
if (typeof title === 'string') {
await addExpensesListMutation({ title });
setIsOpen(false);
}
};
return {
isOpen,
isPending,
onCloseModalHandler,
onClickHandler,
onFormSubmitHandler,
};
};

View File

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

View File

@ -0,0 +1,42 @@
import { Input } from '@/shared/ui/Input';
import { Modal } from '@/shared/ui/Modal';
import { useAddExpensesListButton } from '../hooks/useAddExpensesListButton';
import type { ButtonHTMLAttributes, FC, Ref } from 'react';
type AddExpensesListButtonProps = ButtonHTMLAttributes<HTMLButtonElement> & {
ref?: Ref<HTMLButtonElement>;
};
const AddExpensesListButton: FC<AddExpensesListButtonProps> = (props) => {
const { onClick, ...restProps } = props;
const {
isOpen,
isPending,
onCloseModalHandler,
onClickHandler,
onFormSubmitHandler,
} = useAddExpensesListButton({ onClick });
return (
<>
<button type="button" onClick={onClickHandler} {...restProps}>
Добавить
</button>
<Modal open={isOpen} onClose={onCloseModalHandler}>
<form onSubmit={onFormSubmitHandler}>
<h2>Добавить список расходов</h2>
<Input disabled={isPending} label="Название" name="title" />
<button disabled={isPending} type="submit">Добавить</button>
</form>
</Modal>
</>
);
};
export default AddExpensesListButton;

View File

@ -0,0 +1,36 @@
import { type FormEventHandler, useState } from 'react';
import { type ExpenseList, useDeleteExpensesList } from '@/entity/expenses/list';
type UseDeleteExpensesListButtonArgs = {
id: ExpenseList['id'];
};
export const useDeleteExpensesListButton = (args: UseDeleteExpensesListButtonArgs) => {
const { id } = args;
const [isOpen, setIsOpen] = useState(false);
const { mutateAsync: deleteExpensesListAsync, isPending } = useDeleteExpensesList();
const onClickHandler = () => {
setIsOpen(true);
};
const onCloseModalHandler = () => {
setIsOpen(false);
};
const onFormSubmitHandler: FormEventHandler<HTMLFormElement> = async (event) => {
event.preventDefault();
await deleteExpensesListAsync({ id });
setIsOpen(false);
};
return {
isOpen,
isPending,
onClickHandler,
onCloseModalHandler,
onFormSubmitHandler,
};
};

View File

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

View File

@ -0,0 +1,51 @@
import { Modal } from '@/shared/ui/Modal';
import { useDeleteExpensesListButton } from '../hooks/useDeleteExpensesListButton';
import type { ExpenseList } from '@/entity/expenses/list';
import type { ButtonHTMLAttributes, Ref } from 'react';
type DeleteExpensesListButtonProps = ButtonHTMLAttributes<HTMLButtonElement> & {
list: ExpenseList;
ref?: Ref<HTMLButtonElement>;
};
const DeleteExpensesListButton = (props: DeleteExpensesListButtonProps) => {
const { list, ...restProps } = props;
const { id, title } = list;
const {
isOpen,
isPending,
onCloseModalHandler,
onClickHandler,
onFormSubmitHandler,
} = useDeleteExpensesListButton({ id });
return (
<>
<button type="button" onClick={onClickHandler} {...restProps}>
Удалить
</button>
<Modal open={isOpen} onClose={onCloseModalHandler}>
<form onSubmit={onFormSubmitHandler}>
<h2>Удалить список расходов</h2>
<p>
Вы действительно хотите удалить список расходов
{' '}
{`"${title}"`}
{' '}
?
</p>
<button disabled={isPending} type="submit">Удалить</button>
</form>
</Modal>
</>
);
};
export default DeleteExpensesListButton;

View File

@ -0,0 +1,44 @@
import { type FormEventHandler, useState } from 'react';
import { type ExpenseList, useEditExpensesList } from '@/entity/expenses/list';
type UseEditExpensesListButtonArgs = {
id: ExpenseList['id'];
};
export const useEditExpensesListButton = (args: UseEditExpensesListButtonArgs) => {
const { id } = args;
const [isOpen, setIsOpen] = useState(false);
const { mutateAsync: editExpensesListAsync, isPending } = useEditExpensesList();
const onClickHandler = () => {
setIsOpen(true);
};
const onCloseModalHandler = () => {
setIsOpen(false);
};
const onFormSubmitHandler: FormEventHandler<HTMLFormElement> = async (event) => {
event.preventDefault();
const formData = new FormData(event.currentTarget);
const title = formData.get('title');
if (typeof title === 'string') {
await editExpensesListAsync({
path: { id },
data: { title },
});
setIsOpen(false);
}
};
return {
isOpen,
isPending,
onClickHandler,
onCloseModalHandler,
onFormSubmitHandler,
};
};

View File

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

View File

@ -0,0 +1,49 @@
import { Input } from '@/shared/ui/Input';
import { Modal } from '@/shared/ui/Modal';
import { useEditExpensesListButton } from '../hooks/useEditExpensesListButton';
import type { ExpenseList } from '@/entity/expenses/list';
import type { ButtonHTMLAttributes, FC, Ref } from 'react';
type EditExpensesListButtonProps = ButtonHTMLAttributes<HTMLButtonElement> & {
list: ExpenseList;
ref?: Ref<HTMLButtonElement>;
};
const EditExpensesListButton: FC<EditExpensesListButtonProps> = (props) => {
const { list, ...restProps } = props;
const {
isOpen,
isPending,
onCloseModalHandler,
onClickHandler,
onFormSubmitHandler,
} = useEditExpensesListButton({ id: list.id });
return (
<>
<button type="button" onClick={onClickHandler} {...restProps}>
Редактировать
</button>
<Modal open={isOpen} onClose={onCloseModalHandler}>
<form onSubmit={onFormSubmitHandler}>
<h2>Редактировать список расходов</h2>
<Input
defaultValue={list.title}
disabled={isPending}
label="Название"
name="title"
/>
<button disabled={isPending} type="submit">Сохранить</button>
</form>
</Modal>
</>
);
};
export default EditExpensesListButton;

View File

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

View File

@ -0,0 +1,45 @@
import { useParams } from 'react-router';
import { useExpenseList } from '@/entity/expenses/list';
import { AddExpenseItemButton } from '@/features/expenses/items/AddExpenseItemButton';
import { Spinner } from '@/shared/ui/Spinner';
import { AppLayout } from '@/widgets/AppLayout';
import { ExpenseItemsTable } from '@/widgets/expenses/ExpenseItemsTable';
import type { ExpensePageParams } from '@/shared/router';
const ExpensePage = () => {
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
const { id } = useParams() as ExpensePageParams;
const { data: expenseList, isPending } = useExpenseList(id);
const renderContent = () => {
if (isPending) {
return <Spinner />;
}
if (expenseList?.items?.length && expenseList.items.length > 0) {
return (
<ExpenseItemsTable list={expenseList} />
);
}
return <p>Списков расходов нет</p>;
};
return (
<AppLayout>
<div>
<h1>Списки расходов</h1>
<AddExpenseItemButton listId={id} />
{renderContent()}
</div>
</AppLayout>
);
};
export default ExpensePage;

View File

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

View File

@ -0,0 +1,38 @@
import { useExpensesList } from '@/entity/expenses/list';
import { AddExpensesListButton } from '@/features/expenses/list/AddExpensesListButton';
import { Spinner } from '@/shared/ui/Spinner';
import { AppLayout } from '@/widgets/AppLayout';
import { ExpensesTable } from '@/widgets/expenses/ExpensesTable';
const ExpensesPage = () => {
const { data: expensesList, isPending } = useExpensesList();
const renderContent = () => {
if (isPending) {
return <Spinner />;
}
if (expensesList && expensesList.length > 0) {
return <ExpensesTable />;
}
return <p>Списков расходов нет</p>;
};
return (
<AppLayout>
<div>
<h1>Списки расходов</h1>
<div>
<AddExpensesListButton />
</div>
{renderContent()}
</div>
</AppLayout>
);
};
export default ExpensesPage;

View File

@ -1,51 +1,11 @@
import { useContext, useState } from 'react';
import { AppLayout } from '@/widgets/AppLayout';
import { api } from '@/shared/api/client';
import { AuthContext } from '@/shared/context/AuthContext';
import type { paths } from '@/shared/api/schema';
type Response = paths['/auth/me']['get']['responses']['200']['content']['application/json'];
const fetchProfile = async () => {
const { data } = await api.get<Response>('/auth/me');
return data;
};
const HomePage = () => {
const [info, setInfo] = useState<Response>();
const { accessToken } = useContext(AuthContext);
const onClick = () => {
if (!accessToken) {
return;
}
fetchProfile()
.then((data) => {
// eslint-disable-next-line no-console
console.log('DATA', data);
setInfo(data);
});
};
return (
const HomePage = () => (
<AppLayout>
<div>
Home page only for auth user
<button type="button" onClick={onClick}>
get profile
</button>
<br />
<b>
ID:
{info?.id}
</b>
<h1>Home Page</h1>
</div>
);
};
</AppLayout>
);
export default HomePage;

View File

@ -1,5 +1,7 @@
import axios from 'axios';
import { PAGES } from '@/shared/router';
import type { paths } from './schema';
const BASE_URL = 'http://localhost:3000';
@ -32,11 +34,16 @@ api.interceptors.response.use((response) => response, async (error) => {
if (error.response?.status === 401 && !originalRequest._retry) {
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
originalRequest._retry = true;
const { data: result } = await axios.post<RefreshTokensSuccessResponse>(`${BASE_URL}/auth/refresh`, null, {
withCredentials: true,
});
const { accessToken } = result;
localStorage.setItem('accessToken', accessToken);
try {
const { data: result } = await axios.post<RefreshTokensSuccessResponse>(`${BASE_URL}/auth/refresh`, null, {
withCredentials: true,
});
const { accessToken } = result;
localStorage.setItem('accessToken', accessToken);
} catch {
localStorage.removeItem('accessToken');
location.replace(PAGES.Login);
}
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument,@typescript-eslint/no-unsafe-member-access
return api(error.config);

View File

@ -75,7 +75,7 @@ export interface paths {
* Получить профиль
* @description Получить данные о своем профиле
*/
get: operations["AuthController_me"];
get: operations["AuthController_profile"];
put?: never;
post?: never;
delete?: never;
@ -101,6 +101,106 @@ export interface paths {
patch?: never;
trace?: never;
};
"/expenses/list": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/**
* Найти все листы расходов
* @description Найти все листы расходов
*/
get: operations["ExpensesController_findAllLists"];
put?: never;
/**
* Создать лист расходов
* @description Создать лист расходов
*/
post: operations["ExpensesController_createList"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/expenses/list/{id}": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/**
* Найти лист расходов
* @description Найти лист расходов
*/
get: operations["ExpensesController_findList"];
put?: never;
post?: never;
/**
* Удалить лист расходов
* @description Удалить лист расходов
*/
delete: operations["ExpensesController_deleteList"];
options?: never;
head?: never;
/**
* Редактировать лист расходов
* @description Редактировать лист расходов
*/
patch: operations["ExpensesController_editList"];
trace?: never;
};
"/expenses/list/{id}/items": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
/**
* Создать элемент расхода
* @description Создать элемент расхода
*/
post: operations["ExpensesController_createListItem"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/expenses/list/{listId}/items/{id}": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/**
* Найти элемент расхода
* @description Найти элемент расхода
*/
get: operations["ExpensesController_findListItem"];
put?: never;
post?: never;
/**
* Удалить элемент расхода
* @description Удалить элемент расхода
*/
delete: operations["ExpensesController_deleteListItem"];
options?: never;
head?: never;
/**
* Редактировать элемент расхода
* @description Редактировать элемент расхода
*/
patch: operations["ExpensesController_editListItem"];
trace?: never;
};
}
export type webhooks = Record<string, never>;
export interface components {
@ -156,11 +256,18 @@ export interface components {
/** @description Access токен */
accessToken: string;
};
IncorrectLoginDto: {
LoginValidationDto: {
/** @example 400 */
statusCode: number;
/** @example Неверный логин или пароль */
message: Record<string, never>;
/** @example [
* "Email должен быть строкой.",
* "Некорректный формат email.",
* "Email обязателен для заполнения.",
* "Пароль должен быть строкой.",
* "Пароль обязателен для заполнения.",
* "Пароль должен содержать минимум 6 символов."
* ] */
message: string[];
/** @example Bad Request */
error: string;
};
@ -168,6 +275,161 @@ export interface components {
/** @description Индетификатор аккаунта */
id: string;
};
CreateExpenseListDto: {
/**
* @description Название листа
* @example Ежедневные покупки
*/
title: string;
};
ExpenseListItemDto: {
/** @description ID */
id: string;
/** @description ID списка */
expenseListId: string;
/**
* @description Заголовок расхода
* @example Кофе
*/
title: string;
/**
* @description Описание
* @example Перед работой
*/
description: string | null;
/**
* Format: date-time
* @description Дата
* @example 2025-08-22T04:12:03.726Z
*/
date: string;
/**
* @description Сумма расхода
* @example 175.5
*/
amount: number;
/**
* @description Валюта расхода
* @default RUB
* @example RUB
*/
currency: string | null;
/**
* @description Количество
* @default 1
* @example 2
*/
count: number | null;
/**
* Format: date-time
* @description Дата создания
*/
createdAt: string;
/**
* Format: date-time
* @description Дата обновления
*/
updatedAt: string;
};
ExpenseListDto: {
/** @description ID */
id: string;
/** @description Название листа */
title: string;
/** @description Список расходов */
items: components["schemas"]["ExpenseListItemDto"][] | null;
/**
* Format: date-time
* @description Дата создания
*/
createdAt: string;
/**
* Format: date-time
* @description Дата обновления
*/
updatedAt: string;
};
EditExpenseListDto: {
/**
* @description Название листа
* @example Ежедневные покупки
*/
title: string;
};
DeletedExpenseListDto: {
/** @description ID */
id: string;
};
CreateExpenseListItemDto: {
/**
* @description Заголовок расхода
* @example Кофе
*/
title: string;
/**
* @description Описание
* @example Перед работой
*/
description: string | null;
/**
* Format: date-time
* @description Дата
* @example 2025-08-22T04:12:03.726Z
*/
date: string;
/**
* @description Сумма расхода
* @example 175.5
*/
amount: number;
/**
* @description Валюта расхода
* @default RUB
* @example RUB
*/
currency: string | null;
/**
* @description Количество
* @default 1
* @example 2
*/
count: number | null;
};
EditExpenseListItemDto: {
/**
* @description Заголовок расхода
* @example Кофе
*/
title: string;
/**
* @description Описание
* @example Перед работой
*/
description: string | null;
/**
* Format: date-time
* @description Дата
* @example 2025-08-22T04:12:03.726Z
*/
date: string;
/**
* @description Сумма расхода
* @example 175.5
*/
amount: number;
/**
* @description Валюта расхода
* @default RUB
* @example RUB
*/
currency: string | null;
/**
* @description Количество
* @default 1
* @example 2
*/
count: number | null;
};
};
responses: never;
parameters: never;
@ -241,13 +503,13 @@ export interface operations {
"application/json": components["schemas"]["SuccessLoginDto"];
};
};
/** @description Неверный логин или пароль */
/** @description Ошибка валидации */
400: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["IncorrectLoginDto"];
"application/json": components["schemas"]["LoginValidationDto"];
};
};
};
@ -272,7 +534,7 @@ export interface operations {
};
};
};
AuthController_me: {
AuthController_profile: {
parameters: {
query?: never;
header?: never;
@ -332,4 +594,269 @@ export interface operations {
};
};
};
ExpensesController_findAllLists: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Успешно создан лист расходов */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ExpenseListDto"][];
};
};
};
};
ExpensesController_createList: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["CreateExpenseListDto"];
};
};
responses: {
/** @description Успешно создан лист расходов */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ExpenseListDto"];
};
};
};
};
ExpensesController_findList: {
parameters: {
query?: never;
header?: never;
path: {
/** @description ID листа расходов */
id: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Успешно создан лист расходов */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ExpenseListDto"];
};
};
/** @description Лист расходов не найден */
404: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
ExpensesController_deleteList: {
parameters: {
query?: never;
header?: never;
path: {
/** @description ID листа расходов */
id: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Успешно удален лист расходов */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["DeletedExpenseListDto"];
};
};
/** @description Лист расходов не найден */
404: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
ExpensesController_editList: {
parameters: {
query?: never;
header?: never;
path: {
/** @description ID листа расходов */
id: string;
};
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["EditExpenseListDto"];
};
};
responses: {
/** @description Успешно изменен лист расходов */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ExpenseListDto"];
};
};
/** @description Лист расходов не найден */
404: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
ExpensesController_createListItem: {
parameters: {
query?: never;
header?: never;
path: {
/** @description ID листа расходов */
id: string;
};
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["CreateExpenseListItemDto"];
};
};
responses: {
/** @description Успешно создан элемент расхода */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ExpenseListItemDto"];
};
};
};
};
ExpensesController_findListItem: {
parameters: {
query?: never;
header?: never;
path: {
/** @description ID листа расходов */
listId: string;
/** @description ID элемента */
id: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Успешно найден элемент расхода */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ExpenseListItemDto"];
};
};
/** @description Позиция расходов не найдена */
404: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
ExpensesController_deleteListItem: {
parameters: {
query?: never;
header?: never;
path: {
/** @description ID листа расходов */
listId: string;
/** @description ID элемента */
id: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Успешно удален элемент расхода */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ExpenseListItemDto"];
};
};
/** @description Позиция расходов не найдена */
404: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
ExpensesController_editListItem: {
parameters: {
query?: never;
header?: never;
path: {
/** @description ID листа расходов */
listId: string;
/** @description ID элемента */
id: string;
};
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["EditExpenseListItemDto"];
};
};
responses: {
/** @description Успешно изменен элемент расхода */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ExpenseListItemDto"];
};
};
/** @description Позиция расходов не найдена */
404: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
}

View File

@ -1 +1,2 @@
export { PAGES } from './types';
export type { ExpensePageParams } from './types';

View File

@ -2,4 +2,10 @@ export enum PAGES {
Home = '/',
Login = '/login',
Register = '/register',
Expenses = '/expenses',
Expense = '/expenses/:id',
}
export type ExpensePageParams = {
id: string;
};

View File

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

View File

@ -0,0 +1,27 @@
.wrapper {
display: flex;
width: 100%;
height: 100%;
position: fixed;
top: 0;
left: 0;
background-color: rgba(0, 0, 0, 0.5);
justify-content: center;
align-items: center;
}
.content {
background-color: white;
z-index: 2;
border-radius: 10px;
position: relative;
padding: 30px;
}
.closeIcon {
position: absolute;
top: 10px;
right: 10px;
cursor: pointer;
font-size: 20px;
}

View File

@ -0,0 +1,29 @@
import classes from './Modal.module.css';
import type { FC, ReactNode } from 'react';
type ModalProps = {
open: boolean;
onClose: () => void;
children: ReactNode;
};
const Modal: FC<ModalProps> = (props) => {
const { open, onClose, children } = props;
if (!open) {
return null;
}
return (
<div className={classes.wrapper} onClick={onClose}>
<div className={classes.content} onClick={(e) => { e.stopPropagation(); }}>
<span className={classes.closeIcon} onClick={onClose}>&times;</span>
{children}
</div>
</div>
);
};
export default Modal;

View File

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

View File

@ -0,0 +1,19 @@
.loader {
width: 48px;
height: 48px;
border: 5px solid dimgrey;
border-bottom-color: transparent;
border-radius: 50%;
display: inline-block;
box-sizing: border-box;
animation: rotation 1s linear infinite;
}
@keyframes rotation {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}

View File

@ -0,0 +1,7 @@
import classes from './Spinner.module.css';
const Spinner = () => (
<span className={classes.loader}></span>
);
export default Spinner;

View File

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

View File

@ -0,0 +1,7 @@
.wrapper {
display: flex;
}
.content {
flex-grow: 1;
}

View File

@ -0,0 +1,25 @@
import { SideBar } from '../../Sidebar';
import classes from './AppLayout.module.css';
import type { FC, ReactNode } from 'react';
type AppLayoutProps = {
children: ReactNode;
};
const AppLayout: FC<AppLayoutProps> = (props) => {
const { children } = props;
return (
<div className={classes.wrapper}>
<SideBar />
<div className={classes.content}>
{children}
</div>
</div>
);
};
export default AppLayout;

View File

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

View File

@ -0,0 +1,5 @@
.wrapper {
width: 200px;
height: 100vh;
border: 1px solid dimgrey;
}

View File

@ -0,0 +1,25 @@
import { NavLink } from 'react-router';
import { PAGES } from '@/shared/router';
import classes from './SideBar.module.css';
const SideBar = () => (
<div className={classes.wrapper}>
<ul>
<li>
<NavLink to={PAGES.Home}>
Главная
</NavLink>
</li>
<li>
<NavLink to={PAGES.Expenses}>
Расходы
</NavLink>
</li>
</ul>
</div>
);
export default SideBar;

View File

@ -1,4 +1,4 @@
import { Input } from '@/shared/ui/input';
import { Input } from '@/shared/ui/Input';
import { useLoginForm } from './hooks/useLoginForm';
import classes from './LoginForm.module.css';

View File

@ -1,4 +1,4 @@
import { Input } from '@/shared/ui/input';
import { Input } from '@/shared/ui/Input';
import { useRegisterForm } from './hooks/useRegisterForm';
import classes from './RegisterForm.module.css';

View File

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

View File

@ -0,0 +1,81 @@
import { DeleteExpenseItemButton } from '@/features/expenses/items/DeleteExpenseItemButton';
import { EditExpenseItemButton } from '@/features/expenses/items/EditExpenseItemButton';
import classes from '@/widgets/expenses/ExpensesTable/ui/ExpensesTable.module.css';
import type { ExpenseList } from '@/entity/expenses/list';
import type { FC } from 'react';
type ExpenseItemsTableProps = {
list: ExpenseList;
};
const ExpenseItemsTable: FC<ExpenseItemsTableProps> = (props) => {
const { list } = props;
const { items } = list;
return (
<table className={classes.table}>
<thead>
<tr>
<th>Идентификатор</th>
<th>Название</th>
<th>Описание</th>
<th>Дата</th>
<th>Сумма</th>
<th>Валюта</th>
<th>Количество</th>
<th>Действия</th>
</tr>
</thead>
<tbody>
{items?.map((item) => (
<tr key={item.id}>
<td>
{item.id}
</td>
<td>
{item.title}
</td>
<td>
{item.description}
</td>
<td>
{item.date}
</td>
<td>
{item.amount}
</td>
<td>
{item.currency}
</td>
<td>
{item.count}
</td>
<td>
<EditExpenseItemButton item={item} />
<DeleteExpenseItemButton item={item} />
</td>
</tr>
))}
</tbody>
</table>
);
};
export default ExpenseItemsTable;

View File

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

View File

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

View File

@ -0,0 +1,67 @@
import { NavLink } from 'react-router';
import { useExpensesList } from '@/entity/expenses/list';
import { DeleteExpensesListButton } from '@/features/expenses/list/DeleteExpensesListButton';
import { EditExpensesListButton } from '@/features/expenses/list/EditExpensesListButton';
import classes from './ExpensesTable.module.css';
const ExpensesTable = () => {
const { data: expensesList, isPending, isLoading } = useExpensesList();
if (isPending || isLoading) {
return (
<span>Loading...</span>
);
}
return (
<table className={classes.table}>
<thead>
<tr>
<th>Перейти</th>
<th>Идентификатор</th>
<th>Название</th>
<th>Количество элементов</th>
<th>Действия</th>
</tr>
</thead>
<tbody>
{expensesList?.map((expense) => (
<tr key={expense.id}>
<td>
<NavLink to={`/expenses/${expense.id}`}>
Ссылка
</NavLink>
</td>
<td>
{expense.id}
</td>
<td>
{expense.title}
</td>
<td>
{expense.items?.length || 0}
</td>
<td>
<EditExpensesListButton list={expense} />
<DeleteExpensesListButton list={expense} />
</td>
</tr>
))}
</tbody>
</table>
);
};
export default ExpensesTable;