Compare commits

...

7 Commits

Author SHA1 Message Date
2447bc53af feat: Админ-панель. Добавлены вкладки пользователей и бэкапов. 2025-06-29 16:55:11 +05:00
67963bd395 feat: Улучшена аутентификация и приватные маршруты
fix: Исправлены сообщения об ошибках аутентификации

chore: Добавлена сортировка пользователей по ID
2025-06-29 11:36:32 +05:00
aadc4bf5bd feat: Админ панель, блокировка пользователей
Добавлена возможность блокировки/разблокировки пользователей администратором.
2025-06-29 10:40:02 +05:00
04242d63f1 refactor: appointments UI
Перемещены и переименованы компоненты Appointments.
2025-06-29 09:49:26 +05:00
c3d77738a7 refactor: Перенос логики UI в useMainLayout
Удален useMainLayoutUI и перенесена логика в useMainLayout.
2025-06-28 18:21:53 +05:00
ceee769100 feat: Добавлена блокировка пользователя. 2025-06-28 18:05:21 +05:00
7574b08b25 fix: Исправление UI и API
Исправлены ошибки UI, API и добавлены улучшения.
2025-06-10 08:37:49 +05:00
55 changed files with 927 additions and 540 deletions

29
.gitignore vendored
View File

@ -1 +1,30 @@
/api/.env /api/.env
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
.env
*/.dist/*
*/.idea/*
*/uploads/*

View File

@ -15,6 +15,7 @@ class UsersRepository:
stmt = ( stmt = (
select(User) select(User)
.options(joinedload(User.role)) .options(joinedload(User.role))
.order_by(User.id)
) )
result = await self.db.execute(stmt) result = await self.db.execute(stmt)
return result.scalars().all() return result.scalars().all()

View File

@ -0,0 +1,26 @@
from fastapi import APIRouter, Depends
from starlette.responses import FileResponse
from app.infrastructure.backup_service import BackupService
from app.infrastructure.dependencies import require_admin
from app.settings import settings
router = APIRouter()
@router.post(
"/create/",
response_class=FileResponse,
summary="Create backup",
description="Create backup",
)
async def create_backup(
user=Depends(require_admin),
):
backup_service = BackupService(
db_url=settings.BACKUP_DB_URL,
app_files_dir=settings.FILE_UPLOAD_DIR,
backup_dir=settings.BACKUP_DIR,
pg_dump_path=settings.PG_DUMP_PATH,
)
return await backup_service.create_backup()

View File

@ -69,3 +69,19 @@ async def change_user(
): ):
users_service = UsersService(db) users_service = UsersService(db)
return await users_service.update_user(data, user_id, user.id) return await users_service.update_user(data, user_id, user.id)
@router.post(
'/{user_id}/set-is-block/',
response_model=Optional[UserEntity],
summary='Set is_blocked flag for user',
description='Set is_blocked flag for user',
)
async def set_is_blocked(
user_id: int,
is_blocked: bool,
db: AsyncSession = Depends(get_db),
user=Depends(require_admin),
):
users_service = UsersService(db)
return await users_service.set_is_blocked(user_id, is_blocked, user.id)

View File

@ -0,0 +1,30 @@
"""0005_добавил блокировку пользователя
Revision ID: 9e7ab1a46b64
Revises: 69fee5fc14c8
Create Date: 2025-06-15 13:41:20.591874
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '9e7ab1a46b64'
down_revision: Union[str, None] = '69fee5fc14c8'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.add_column('users', sa.Column('is_blocked', sa.Boolean(), server_default='false', nullable=False))
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_column('users', 'is_blocked')
# ### end Alembic commands ###

View File

@ -0,0 +1,40 @@
"""0006 добавил таблицу backups
Revision ID: b58238896c0f
Revises: 9e7ab1a46b64
Create Date: 2025-06-29 16:50:16.638528
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = 'b58238896c0f'
down_revision: Union[str, None] = '9e7ab1a46b64'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('backups',
sa.Column('timestamp', sa.DateTime(), nullable=False),
sa.Column('path', sa.String(), nullable=False),
sa.Column('filename', sa.String(), nullable=False),
sa.Column('user_id', sa.Integer(), nullable=False),
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
sa.Column('created_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False),
sa.Column('updated_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False),
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ),
sa.PrimaryKeyConstraint('id')
)
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_table('backups')
# ### end Alembic commands ###

View File

@ -11,6 +11,7 @@ class UserEntity(BaseModel):
last_name: str last_name: str
patronymic: Optional[str] = None patronymic: Optional[str] = None
login: str login: str
is_blocked: Optional[bool] = None
role_id: Optional[int] = None role_id: Optional[int] = None

View File

@ -4,6 +4,7 @@ Base = declarative_base()
from app.domain.models.appointment_files import AppointmentFile from app.domain.models.appointment_files import AppointmentFile
from app.domain.models.appointments import Appointment from app.domain.models.appointments import Appointment
from app.domain.models.backups import Backup
from app.domain.models.appointment_types import AppointmentType from app.domain.models.appointment_types import AppointmentType
from app.domain.models.lens_types import LensType from app.domain.models.lens_types import LensType
from app.domain.models.lens_issues import LensIssue from app.domain.models.lens_issues import LensIssue

View File

@ -0,0 +1,17 @@
import datetime
from sqlalchemy import Column, DateTime, String, Integer, ForeignKey
from app.domain.models.base import BaseModel
class Backup(BaseModel):
__tablename__ = 'backups'
timestamp = Column(DateTime, nullable=False, default=datetime.datetime.now)
path = Column(String, nullable=False)
filename = Column(String, nullable=False)
user_id = Column(Integer, ForeignKey('users.id'), nullable=False)

View File

@ -1,4 +1,4 @@
from sqlalchemy import Column, Integer, VARCHAR, ForeignKey, String from sqlalchemy import Column, Integer, VARCHAR, ForeignKey, String, Boolean
from sqlalchemy.orm import relationship from sqlalchemy.orm import relationship
from werkzeug.security import check_password_hash, generate_password_hash from werkzeug.security import check_password_hash, generate_password_hash
@ -13,6 +13,7 @@ class User(BaseModel):
patronymic = Column(VARCHAR(200)) patronymic = Column(VARCHAR(200))
login = Column(String, nullable=False, unique=True) login = Column(String, nullable=False, unique=True)
password = Column(String, nullable=False) password = Column(String, nullable=False)
is_blocked = Column(Boolean, nullable=False, default=False, server_default='false')
role_id = Column(Integer, ForeignKey('roles.id'), nullable=False) role_id = Column(Integer, ForeignKey('roles.id'), nullable=False)

View File

@ -0,0 +1,40 @@
import subprocess
import os
import tarfile
import datetime
from fastapi import HTTPException
from starlette.responses import FileResponse
class BackupService:
def __init__(self, db_url: str, app_files_dir: str, backup_dir: str, pg_dump_path: str):
self.db_url = db_url
self.app_files_dir = app_files_dir
self.backup_dir = backup_dir
self.pg_dump_path = pg_dump_path
os.makedirs(backup_dir, exist_ok=True)
async def create_backup(self) -> FileResponse:
try:
timestamp = datetime.datetime.now().strftime("%Y%m%d_%H%M%S")
backup_name = f"backup_{timestamp}"
backup_path = os.path.join(self.backup_dir, backup_name)
db_dump_path = f"{os.getcwd()}/{backup_path}.sql"
dump_cmd = f'"{self.pg_dump_path}" -Fc -d {self.db_url} -f "{db_dump_path}"'
subprocess.run(dump_cmd, shell=True, check=True)
with tarfile.open(f"{backup_path}.tar.gz", "w:gz") as tar:
tar.add(self.app_files_dir, arcname=self.app_files_dir)
tar.add(db_dump_path, arcname="db_dump.sql")
os.remove(db_dump_path)
return FileResponse(
f"{backup_path}.tar.gz",
media_type="application/gzip",
filename=backup_name,
)
except subprocess.CalledProcessError as e:
print(e)
raise HTTPException(500, f"Ошибка создания бэкапа: {e}")

View File

@ -21,17 +21,20 @@ async def get_current_user(
try: try:
payload = jwt.decode(credentials.credentials, auth_data["secret_key"], algorithms=[auth_data["algorithm"]]) payload = jwt.decode(credentials.credentials, auth_data["secret_key"], algorithms=[auth_data["algorithm"]])
except jwt.ExpiredSignatureError: except jwt.ExpiredSignatureError:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Token has expired") raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Срок действия токена истек")
except jwt.InvalidTokenError: except jwt.InvalidTokenError:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token") raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Неправильный токен")
user_id = payload.get("user_id") user_id = payload.get("user_id")
if user_id is None: if user_id is None:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token") raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Неправильный токен")
user = await UsersRepository(db).get_by_id_with_role(user_id) user = await UsersRepository(db).get_by_id_with_role(user_id)
if user is None: if user is None:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="User not found") raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Пользователь не найден")
if user.is_blocked:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Пользователь заблокирован")
return user return user

View File

@ -122,6 +122,33 @@ class UsersService:
role_id=created_user.role_id, role_id=created_user.role_id,
) )
async def set_is_blocked(self, user_id: int, is_blocked: bool, current_user_id: int) -> Optional[UserEntity]:
user_model = await self.users_repository.get_by_id(user_id)
if not user_model:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='Пользователь не найден',
)
current_user = await self.users_repository.get_by_id(current_user_id)
if not current_user:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='Пользователь не найден',
)
if current_user_id == user_id:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='Администратор не может заблокировать и разблокировать сам себя',
)
user_model.is_blocked = is_blocked
user_model = await self.users_repository.update(user_model)
return self.model_to_entity(user_model)
async def update_user(self, user: UserEntity, user_id: int, current_user_id: int) -> Optional[UserEntity]: async def update_user(self, user: UserEntity, user_id: int, current_user_id: int) -> Optional[UserEntity]:
user_model = await self.users_repository.get_by_id(user_id) user_model = await self.users_repository.get_by_id(user_id)
if not user_model: if not user_model:
@ -180,6 +207,7 @@ class UsersService:
last_name=user.last_name, last_name=user.last_name,
patronymic=user.patronymic, patronymic=user.patronymic,
login=user.login, login=user.login,
is_blocked=user.is_blocked,
) )
if user.id is not None: if user.id is not None:
@ -196,4 +224,5 @@ class UsersService:
patronymic=user.patronymic, patronymic=user.patronymic,
login=user.login, login=user.login,
role_id=user.role_id, role_id=user.role_id,
is_blocked=user.is_blocked,
) )

View File

@ -5,6 +5,7 @@ from app.controllers.appointment_files_router import router as appointment_files
from app.controllers.appointment_types_router import router as appointments_types_router from app.controllers.appointment_types_router import router as appointments_types_router
from app.controllers.appointments_router import router as appointment_router from app.controllers.appointments_router import router as appointment_router
from app.controllers.auth_router import router as auth_router from app.controllers.auth_router import router as auth_router
from app.controllers.backup_router import router as backups_router
from app.controllers.lens_issues_router import router as lens_issues_router from app.controllers.lens_issues_router import router as lens_issues_router
from app.controllers.lens_types_router import router as lens_types_router from app.controllers.lens_types_router import router as lens_types_router
from app.controllers.lenses_router import router as lenses_router from app.controllers.lenses_router import router as lenses_router
@ -35,6 +36,7 @@ def start_app():
tags=['appointment_types']) tags=['appointment_types'])
api_app.include_router(appointment_router, prefix=f'{settings.APP_PREFIX}/appointments', tags=['appointments']) api_app.include_router(appointment_router, prefix=f'{settings.APP_PREFIX}/appointments', tags=['appointments'])
api_app.include_router(auth_router, prefix=settings.APP_PREFIX, tags=['auth']) api_app.include_router(auth_router, prefix=settings.APP_PREFIX, tags=['auth'])
api_app.include_router(backups_router, prefix=f'{settings.APP_PREFIX}/backups', tags=['backups'])
api_app.include_router(lens_issues_router, prefix=f'{settings.APP_PREFIX}/lens_issues', tags=['lens_issue']) api_app.include_router(lens_issues_router, prefix=f'{settings.APP_PREFIX}/lens_issues', tags=['lens_issue'])
api_app.include_router(lens_types_router, prefix=f'{settings.APP_PREFIX}/lens_types', tags=['lens_types']) api_app.include_router(lens_types_router, prefix=f'{settings.APP_PREFIX}/lens_types', tags=['lens_types'])
api_app.include_router(lenses_router, prefix=f'{settings.APP_PREFIX}/lenses', tags=['lenses']) api_app.include_router(lenses_router, prefix=f'{settings.APP_PREFIX}/lenses', tags=['lenses'])

View File

@ -8,6 +8,10 @@ class Settings(BaseSettings):
SECRET_KEY: str SECRET_KEY: str
ALGORITHM: str ALGORITHM: str
APP_PREFIX: str = '/api/v1' APP_PREFIX: str = '/api/v1'
FILE_UPLOAD_DIR: str = 'uploads'
BACKUP_DIR: str = 'backups'
BACKUP_DB_URL: str
PG_DUMP_PATH: str
class Config: class Config:
env_file = '.env' env_file = '.env'

View File

@ -11,8 +11,8 @@ service:
resources: resources:
limits: limits:
memory: 512Mi memory: 128Mi
cpu: 500m cpu: 200m
persistence: persistence:
path: /mnt/k8s_storage/visus-api path: /mnt/k8s_storage/visus-api

View File

@ -11,8 +11,8 @@ service:
resources: resources:
limits: limits:
memory: 512Mi memory: 128Mi
cpu: 500m cpu: 200m
ingress: ingress:
secretTLSName: visus-web-tls-secret secretTLSName: visus-web-tls-secret

View File

@ -1,10 +1,10 @@
import { fetchBaseQuery } from '@reduxjs/toolkit/query/react'; import {fetchBaseQuery} from '@reduxjs/toolkit/query/react';
import { logout } from '../Redux/Slices/authSlice.js'; import {logout} from '../Redux/Slices/authSlice.js';
import CONFIG from "../Core/сonfig.js"; import CONFIG from "../Core/сonfig.js";
export const baseQuery = fetchBaseQuery({ export const baseQuery = fetchBaseQuery({
baseUrl: CONFIG.BASE_URL, baseUrl: CONFIG.BASE_URL,
prepareHeaders: (headers, { getState, endpoint }) => { prepareHeaders: (headers, {getState, endpoint}) => {
const token = localStorage.getItem('access_token'); const token = localStorage.getItem('access_token');
if (token) { if (token) {
headers.set('Authorization', `Bearer ${token}`); headers.set('Authorization', `Bearer ${token}`);
@ -25,7 +25,7 @@ export const baseQuery = fetchBaseQuery({
export const baseQueryWithAuth = async (args, api, extraOptions) => { export const baseQueryWithAuth = async (args, api, extraOptions) => {
const result = await baseQuery(args, api, extraOptions); const result = await baseQuery(args, api, extraOptions);
if (result.error && result.error.status === 401) { if (result.error && [401, 403].includes(result.error.status)) {
localStorage.removeItem('access_token'); localStorage.removeItem('access_token');
api.dispatch(logout()); api.dispatch(logout());
window.location.href = '/login'; window.location.href = '/login';

View File

@ -44,7 +44,7 @@ export const lensesApi = createApi({
method: 'POST', method: 'POST',
body: lens body: lens
}), }),
invalidatesTags: ['Lens'] invalidatesTags: ['Lenses']
}), }),
updateLens: builder.mutation({ updateLens: builder.mutation({
query: ({id, ...lens}) => ({ query: ({id, ...lens}) => ({
@ -52,14 +52,14 @@ export const lensesApi = createApi({
method: 'PUT', method: 'PUT',
body: lens body: lens
}), }),
invalidatesTags: ['Lens'] invalidatesTags: ['Lenses']
}), }),
deleteLens: builder.mutation({ deleteLens: builder.mutation({
query: (id) => ({ query: (id) => ({
url: `/lenses/${id}/`, url: `/lenses/${id}/`,
method: 'DELETE' method: 'DELETE'
}), }),
invalidatesTags: ['Lens'] invalidatesTags: ['Lenses']
}), }),
}), }),
}); });

View File

@ -23,7 +23,7 @@ export const patientsApi = createApi({
url: '/patients/', url: '/patients/',
params: { all_params: true }, params: { all_params: true },
}), }),
providesTags: ['Patient'], providesTags: ['Patients'],
transformResponse: (response) => { transformResponse: (response) => {
if (!response || !Array.isArray(response.patients)) { if (!response || !Array.isArray(response.patients)) {
console.warn('Unexpected patients API response:', response); console.warn('Unexpected patients API response:', response);
@ -38,7 +38,7 @@ export const patientsApi = createApi({
method: 'POST', method: 'POST',
body: patient body: patient
}), }),
invalidatesTags: ['Patient'] invalidatesTags: ['Patients']
}), }),
updatePatient: builder.mutation({ updatePatient: builder.mutation({
query: ({id, ...patient}) => ({ query: ({id, ...patient}) => ({
@ -46,14 +46,14 @@ export const patientsApi = createApi({
method: 'PUT', method: 'PUT',
body: patient body: patient
}), }),
invalidatesTags: ['Patient'] invalidatesTags: ['Patients']
}), }),
deletePatient: builder.mutation({ deletePatient: builder.mutation({
query: (id) => ({ query: (id) => ({
url: `/patients/${id}/`, url: `/patients/${id}/`,
method: 'DELETE' method: 'DELETE'
}), }),
invalidatesTags: ['Patient'] invalidatesTags: ['Patients']
}), }),
}), }),
}); });

View File

@ -32,6 +32,13 @@ export const usersApi = createApi({
}), }),
invalidatesTags: ['User'] invalidatesTags: ['User']
}), }),
setIsBlocked: builder.mutation({
query: ({ userId, isBlocked }) => ({
url: `/users/${userId}/set-is-block/?is_blocked=${isBlocked}`,
method: "POST",
}),
invalidatesTags: ['User'],
}),
}), }),
}); });
@ -40,4 +47,5 @@ export const {
useChangePasswordMutation, useChangePasswordMutation,
useUpdateUserMutation, useUpdateUserMutation,
useGetAllUsersQuery, useGetAllUsersQuery,
useSetIsBlockedMutation
} = usersApi; } = usersApi;

View File

@ -9,7 +9,7 @@ const AdminRoute = () => {
isLoading: isUserLoading, isLoading: isUserLoading,
isError: isUserError, isError: isUserError,
} = useGetAuthenticatedUserDataQuery(undefined, { } = useGetAuthenticatedUserDataQuery(undefined, {
pollingInterval: 60000, pollingInterval: 20000,
}); });
if (isUserLoading) { if (isUserLoading) {

View File

@ -1,19 +1,19 @@
import { Navigate, Outlet } from "react-router-dom"; import {Navigate, Outlet} from "react-router-dom";
import { useSelector } from "react-redux"; import {useSelector} from "react-redux";
import LoadingIndicator from "../Components/Widgets/LoadingIndicator/LoadingIndicator.jsx"; import LoadingIndicator from "../Components/Widgets/LoadingIndicator/LoadingIndicator.jsx";
const PrivateRoute = () => { const PrivateRoute = () => {
const { user, userData, isLoading } = useSelector((state) => state.auth); const {user, userData, isLoading} = useSelector((state) => state.auth);
if (isLoading) { if (isLoading) {
return <LoadingIndicator />; return <LoadingIndicator/>;
} }
if (!user || !userData) { if (!user || !userData || userData.is_blocked) {
return <Navigate to="/login" />; return <Navigate to="/login"/>;
} }
return <Outlet />; return <Outlet/>;
}; };
export default PrivateRoute; export default PrivateRoute;

View File

@ -229,6 +229,7 @@ const AppointmentFormModal = () => {
const ConfirmStep = useMemo(() => { const ConfirmStep = useMemo(() => {
const values = appointmentFormModalUI.form.getFieldsValue(); const values = appointmentFormModalUI.form.getFieldsValue();
const formValues = appointmentFormModalUI.formValues;
const patient = appointmentFormModalData.patients.find((p) => p.id === values.patient_id); const patient = appointmentFormModalData.patients.find((p) => p.id === values.patient_id);
const appointmentType = appointmentFormModalData.appointmentTypes.find((t) => t.id === values.type_id); const appointmentType = appointmentFormModalData.appointmentTypes.find((t) => t.id === values.type_id);
@ -251,7 +252,7 @@ const AppointmentFormModal = () => {
<p> <p>
<b>Результаты приема:</b> <b>Результаты приема:</b>
</p> </p>
<div dangerouslySetInnerHTML={{__html: values.results || "Не указаны"}}/> <div dangerouslySetInnerHTML={{__html: formValues.results || "Не указаны"}}/>
<p> <p>
<b>Прикрепленные файлы:</b> <b>Прикрепленные файлы:</b>
</p> </p>

View File

@ -422,6 +422,7 @@ const useAppointmentFormModalUI = (createAppointment, patients, cancelAppointmen
screenXS, screenXS,
direction, direction,
isDrawerVisible, isDrawerVisible,
formValues,
showDrawer, showDrawer,
closeDrawer, closeDrawer,
isLoadingPreviousAppointments, isLoadingPreviousAppointments,

View File

@ -1,4 +1,4 @@
import {Button, Form, Input, Modal, Select, Space, Typography} from "antd"; import {Button, Form, Input, Modal, Select, Space, Switch, Typography} from "antd";
import useUpdateUserModalFormUI from "./useUpdateUserModalFormUI.js"; import useUpdateUserModalFormUI from "./useUpdateUserModalFormUI.js";
import useUpdateUserModalForm from "./useUpdateUserModalForm.js"; import useUpdateUserModalForm from "./useUpdateUserModalForm.js";

View File

@ -1,4 +1,4 @@
import {Alert, Layout, Menu, Result} from "antd"; import {Layout, Menu, Result} from "antd";
import {Outlet} from "react-router-dom"; import {Outlet} from "react-router-dom";
import { import {
HomeOutlined, HomeOutlined,
@ -11,34 +11,30 @@ import {
MessageOutlined, ControlOutlined MessageOutlined, ControlOutlined
} from "@ant-design/icons"; } from "@ant-design/icons";
import useMainLayout from "./useMainLayout.js"; import useMainLayout from "./useMainLayout.js";
import useMainLayoutUI from "./useMainLayoutUI.js";
import LoadingIndicator from "../Widgets/LoadingIndicator/LoadingIndicator.jsx"; import LoadingIndicator from "../Widgets/LoadingIndicator/LoadingIndicator.jsx";
const {Content, Footer, Sider} = Layout; const {Content, Footer, Sider} = Layout;
const MainLayout = () => { const MainLayout = () => {
const mainLayoutData = useMainLayout(); const mainLayoutData = useMainLayout();
const mainLayoutUI = useMainLayoutUI(mainLayoutData.user);
const menuItems = [ const menuItems = [
mainLayoutUI.getItem("Главная", "/", <HomeOutlined />), mainLayoutData.getItem("Главная", "/", <HomeOutlined/>),
mainLayoutUI.getItem("Приёмы", "/appointments", <CalendarOutlined />), mainLayoutData.getItem("Приёмы", "/appointments", <CalendarOutlined/>),
mainLayoutUI.getItem("Выдачи линз", "/issues", <DatabaseOutlined />), mainLayoutData.getItem("Выдачи линз", "/issues", <DatabaseOutlined/>),
mainLayoutUI.getItem("Линзы и наборы", "/Lenses", <FolderViewOutlined />), mainLayoutData.getItem("Линзы и наборы", "/Lenses", <FolderViewOutlined/>),
mainLayoutUI.getItem("Пациенты", "/Patients", <TeamOutlined />), mainLayoutData.getItem("Пациенты", "/Patients", <TeamOutlined/>),
mainLayoutUI.getItem("Рассылки", "/mailing", <MessageOutlined />), mainLayoutData.getItem("Рассылки", "/mailing", <MessageOutlined/>),
{ type: "divider" } {type: "divider"}
]; ];
if (mainLayoutData.user?.role.title === "Администратор") { if (mainLayoutData.user?.role.title === "Администратор") {
menuItems.push(mainLayoutUI.getItem("Панель администратора", "/admin", <ControlOutlined />)); menuItems.push(mainLayoutData.getItem("Панель администратора", "/admin", <ControlOutlined/>));
} }
menuItems.push( menuItems.push(
mainLayoutUI.getItem("Мой профиль", "profile", <UserOutlined />, [ mainLayoutData.getItem("Перейти в профиль", "/profile", <UserOutlined/>),
mainLayoutUI.getItem("Перейти в профиль", "/profile", <UserOutlined />), mainLayoutData.getItem("Выйти", "logout", <LogoutOutlined/>)
mainLayoutUI.getItem("Выйти", "logout", <LogoutOutlined />)
])
); );
if (mainLayoutData.isUserError) { if (mainLayoutData.isUserError) {
@ -47,17 +43,67 @@ const MainLayout = () => {
return ( return (
<Layout style={{minHeight: "100vh"}}> <Layout style={{minHeight: "100vh"}}>
{mainLayoutData.screens.xs ? (
<>
<Content style={{
margin: "0 8px",
padding: 24,
flex: 1,
overflow: "auto",
background: "#fff",
borderRadius: 8,
marginTop: "8px"
}}>
{mainLayoutData.isUserLoading ? (
<LoadingIndicator/>
) : (
<Outlet/>
)}
</Content>
<Layout.Header style={{
position: "fixed",
bottom: 0,
left: 0,
right: 0,
display: "flex",
alignItems: "center",
padding: "0 16px",
background: "#001529",
zIndex: 1000
}}>
<div style={{display: "flex", alignItems: "center", paddingRight: 16}}>
<img
src="/logo_rounded.png"
alt="Логотип"
style={{width: 40}}
/>
</div>
<Menu
theme="dark"
mode="horizontal"
selectedKeys={[mainLayoutData.location.pathname]}
items={menuItems}
onClick={mainLayoutData.handleMenuClick}
style={{flex: 1, minWidth: 0}}
/>
</Layout.Header>
<Footer style={{textAlign: "center", padding: "12px 0", marginBottom: 48}}>
Visus+ © {new Date().getFullYear()}
</Footer>
</>
) : (
<>
<Sider <Sider
collapsible={!mainLayoutUI.screens.xs} collapsible={!mainLayoutData.screens.xs}
collapsed={mainLayoutUI.collapsed} collapsed={mainLayoutData.collapsed}
onCollapse={mainLayoutUI.setCollapsed} onCollapse={mainLayoutData.setCollapsed}
style={{height: "100vh", position: "fixed", left: 0}} style={{height: "100vh", position: "fixed", left: 0, overflow: "auto"}}
> >
<div style={{display: "flex", justifyContent: "center", padding: 16}}> <div style={{display: "flex", justifyContent: "center", padding: 16}}>
<img <img
src="/logo_rounded.png" src="/logo_rounded.png"
alt="Логотип" alt="Логотип"
style={{width: mainLayoutUI.collapsed ? 40 : 80, transition: "width 0.2s"}} style={{width: mainLayoutData.collapsed ? 40 : 80, transition: "width 0.2s"}}
/> />
</div> </div>
<Menu <Menu
@ -65,12 +111,12 @@ const MainLayout = () => {
selectedKeys={[location.pathname]} selectedKeys={[location.pathname]}
mode="inline" mode="inline"
items={menuItems} items={menuItems}
onClick={mainLayoutUI.handleMenuClick} onClick={mainLayoutData.handleMenuClick}
/> />
</Sider> </Sider>
<Layout <Layout
style={{marginLeft: mainLayoutUI.collapsed ? 80 : 200, transition: "margin-left 0.2s"}} style={{marginLeft: mainLayoutData.collapsed ? 80 : 200, transition: "margin-left 0.2s"}}
> >
<Content style={{ <Content style={{
margin: "0 16px", margin: "0 16px",
@ -89,6 +135,9 @@ const MainLayout = () => {
</Content> </Content>
<Footer style={{textAlign: "center"}}>Visus+ © {new Date().getFullYear()}</Footer> <Footer style={{textAlign: "center"}}>Visus+ © {new Date().getFullYear()}</Footer>
</Layout> </Layout>
</>
)}
</Layout> </Layout>
); );
}; };

View File

@ -1,13 +1,33 @@
import { useSelector } from "react-redux"; import {useState} from "react";
import {Grid} from "antd";
import {useLocation, useNavigate} from "react-router-dom";
import useAuthUtils from "../../Hooks/useAuthUtils.js";
import {useSelector} from "react-redux";
const {useBreakpoint} = Grid;
const useMainLayout = () => { const useMainLayout = () => {
const screens = useBreakpoint();
const [collapsed, setCollapsed] = useState(true);
const navigate = useNavigate();
const location = useLocation();
const {logoutAndRedirect} = useAuthUtils();
const { userData: user, isLoading: isUserLoading, error: isUserError } = useSelector((state) => state.auth); const { userData: user, isLoading: isUserLoading, error: isUserError } = useSelector((state) => state.auth);
return { const handleMenuClick = ({key}) => {
user, if (key === "logout") {
isUserLoading, logoutAndRedirect();
isUserError, return;
}
navigate(key);
}; };
const getItem = (label, key, icon, children) => ({key, icon, children, label});
return {screens, collapsed, setCollapsed, location, user, isUserLoading, isUserError, handleMenuClick, getItem};
}; };
export default useMainLayout; export default useMainLayout;

View File

@ -1,30 +0,0 @@
import {useState} from "react";
import {Grid} from "antd";
import {useLocation, useNavigate} from "react-router-dom";
import useAuthUtils from "../../Hooks/useAuthUtils.js";
const {useBreakpoint} = Grid;
const useMainLayoutUI = () => {
const screens = useBreakpoint();
const [collapsed, setCollapsed] = useState(true);
const navigate = useNavigate();
const location = useLocation();
const {logoutAndRedirect} = useAuthUtils();
const handleMenuClick = ({key}) => {
if (key === "logout") {
logoutAndRedirect();
return;
}
navigate(key);
};
const getItem = (label, key, icon, children) => ({key, icon, children, label});
return {screens, collapsed, setCollapsed, location, handleMenuClick, getItem};
};
export default useMainLayoutUI;

View File

@ -1,90 +1,43 @@
import {Table, Button, Result, Typography} from "antd"; import {Tabs, Typography} from "antd";
import {ControlOutlined} from "@ant-design/icons"; import {
import LoadingIndicator from "../../Widgets/LoadingIndicator/LoadingIndicator.jsx"; ContainerOutlined,
ControlOutlined,
UserOutlined
} from "@ant-design/icons";
import useAdminPage from "./useAdminPage.js"; import useAdminPage from "./useAdminPage.js";
import useAdminPageUI from "./useAdminPageUI.js"; import UsersManageTab from "./Components/UsersManageTab/UsersManageTab.jsx";
import CreateUserModalForm from "./Components/CreateUserModalForm/CreateUserModalForm.jsx"; import BackupManageTab from "./Components/BackupManageTab/BackupManageTab.jsx";
import UpdateUserModalForm from "../../Dummies/UpdateUserModalForm/UpdateUserModalForm.jsx";
const items = [
{
key: '1',
label: 'Пользователи',
children: <UsersManageTab/>,
icon: <UserOutlined/>
},
{
key: '2',
label: 'Резервное копирование и восстановление',
children: <BackupManageTab/>,
icon: <ContainerOutlined/>
}
]
const AdminPage = () => { const AdminPage = () => {
const adminPageData = useAdminPage(); const adminPageData = useAdminPage();
const adminPageUI = useAdminPageUI();
const columns = [
{
title: "ID",
dataIndex: "id",
key: "id",
},
{
title: "Фамилия",
dataIndex: "last_name",
key: "lastName",
sorter: (a, b) => a.last_name.localeCompare(b.last_name),
},
{
title: "Имя",
dataIndex: "first_name",
key: "firstName",
sorter: (a, b) => a.first_name.localeCompare(b.first_name),
},
{
title: "Отчество",
dataIndex: "patronymic",
key: "patronymic",
},
{
title: "Роль",
dataIndex: ["role", "title"],
key: "role",
filters: adminPageData.roles.map(role => ({text: role.title, value: role.title})),
onFilter: (value, record) => record.role.title === value,
},
{
title: "Действия",
key: "actions",
render: (_, record) => (
<Button type="link" onClick={() => adminPageUI.openEditModal(record)}>
Редактировать
</Button>
),
},
];
if (adminPageData.isError) {
return <Result status="500" title="500" subTitle="Произошла ошибка при загрузке данных"/>
}
return ( return (
<div style={adminPageUI.containerStyle}> <div style={adminPageData.containerStyle}>
{adminPageData.isLoading ? (
<LoadingIndicator/>
) : (
<> <>
<Typography.Title level={1}> <Typography.Title level={1}>
<ControlOutlined/> Панель администратора <ControlOutlined/> Панель администратора
</Typography.Title> </Typography.Title>
<Button <Tabs
type="primary" defaultActiveKey="1"
onClick={adminPageUI.openCreateModal} items={items}
style={{marginBottom: 16}}
>
Создать пользователя
</Button>
<Table
columns={columns}
dataSource={adminPageData.users}
rowKey="id"
pagination={{pageSize: 10}}
loading={adminPageData.isLoading}
/> />
<CreateUserModalForm/>
<UpdateUserModalForm/>
</> </>
)}
</div> </div>
); );
}; };

View File

@ -0,0 +1,38 @@
import {Button, Space, Spin, Typography, Upload} from "antd";
import {CloudDownloadOutlined, UploadOutlined} from "@ant-design/icons";
const BackupManageTab = () => {
return (
<Spin spinning={false}>
<Typography>
<Typography.Title level={4}>Управление резервными копиями</Typography.Title>
<Typography.Paragraph>
Здесь вы можете создать резервную копию системы и восстановить её из архива.
</Typography.Paragraph>
</Typography>
<Space direction="vertical" size="large" style={{width: "100%"}}>
<Button
type="primary"
icon={<CloudDownloadOutlined/>}
// onClick={handleCreateBackup}
// disabled={loading}
block
>
Создать и скачать бэкап
</Button>
<Upload
// beforeUpload={handleUpload}
showUploadList={false}
accept=".tar.gz,.zip"
// disabled={loading}
>
<Button icon={<UploadOutlined/>} block>
Загрузить бэкап для восстановления
</Button>
</Upload>
</Space>
</Spin>
);
};
export default BackupManageTab;

View File

@ -0,0 +1,99 @@
import {Button, FloatButton, Input, Result, Switch, Table, Tooltip} from "antd";
import CreateUserModalForm from "../CreateUserModalForm/CreateUserModalForm.jsx";
import UpdateUserModalForm from "../../../../Dummies/UpdateUserModalForm/UpdateUserModalForm.jsx";
import useUsersManageTab from "./useUsersManageTab.js";
import {PlusOutlined} from "@ant-design/icons";
import LoadingIndicator from "../../../../Widgets/LoadingIndicator/LoadingIndicator.jsx";
const UsersManageTab = () => {
const usersManageTabData = useUsersManageTab();
const columns = [
{
title: "ID",
dataIndex: "id",
key: "id",
},
{
title: "Фамилия",
dataIndex: "last_name",
key: "lastName",
sorter: (a, b) => a.last_name.localeCompare(b.last_name),
},
{
title: "Имя",
dataIndex: "first_name",
key: "firstName",
sorter: (a, b) => a.first_name.localeCompare(b.first_name),
},
{
title: "Отчество",
dataIndex: "patronymic",
key: "patronymic",
},
{
title: "Роль",
dataIndex: ["role", "title"],
key: "role",
filters: usersManageTabData.roles.map(role => ({text: role.title, value: role.title})),
onFilter: (value, record) => record.role.title === value,
},
{
title: "Заблокирован",
dataIndex: "is_blocked",
key: "is_blocked",
render: (value, record) => (
<Switch
loading={usersManageTabData.isBlocking}
checked={value}
checkedChildren="Заблокирован"
unCheckedChildren="Не заблокирован"
onChange={(isBlocked) => usersManageTabData.setIsBlockUser(record.id, isBlocked)}
/>
),
},
{
title: "Действия",
key: "actions",
render: (_, record) => (
<Button type="link" onClick={() => usersManageTabData.openEditModal(record)}>
Редактировать
</Button>
),
},
];
if (usersManageTabData.isError) {
return <Result status="500" title="500" subTitle="Произошла ошибка при загрузке данных"/>;
}
if (usersManageTabData.isLoading) {
return <LoadingIndicator/>;
}
return (
<>
<Input
placeholder="Введите фамилию, имя или отчество"
style={{marginBottom: 12}}
allowClear
onChange={usersManageTabData.handleSearch}
/>
<Table
columns={columns}
dataSource={usersManageTabData.filteredUsers}
rowKey="id"
pagination={{pageSize: 10}}
/>
<Tooltip title="Добавить пользователя">
<FloatButton onClick={usersManageTabData.openCreateModal} icon={<PlusOutlined/>} type={"primary"}/>
</Tooltip>
<CreateUserModalForm/>
<UpdateUserModalForm/>
</>
)
};
export default UsersManageTab;

View File

@ -0,0 +1,93 @@
import {useGetAllUsersQuery, useSetIsBlockedMutation} from "../../../../../Api/usersApi.js";
import {useGetRolesQuery} from "../../../../../Api/rolesApi.js";
import {Grid, notification} from "antd";
import {useDispatch} from "react-redux";
import {setIsCurrentUser, setSelectedUser} from "../../../../../Redux/Slices/usersSlice.js";
import {openModal} from "../../../../../Redux/Slices/adminSlice.js";
import {useMemo, useState} from "react";
const {useBreakpoint} = Grid;
const useUsersManageTab = () => {
const dispatch = useDispatch();
const screens = useBreakpoint();
const [searchString, setSearchString] = useState("");
const {
data: users = [], isLoading, isError,
} = useGetAllUsersQuery(undefined, {
pollingInterval: 10000,
});
const {data: roles = [], isLoading: isLoadingRoles, isError: isErrorRoles} = useGetRolesQuery(undefined, {
pollingInterval: 60000,
});
const [setIsBlocked, {isLoading: isBlocking, isError: isBlockError}] = useSetIsBlockedMutation();
const containerStyle = {padding: screens.xs ? 16 : 24};
const openEditModal = (user) => {
dispatch(setSelectedUser(user));
dispatch(setIsCurrentUser(true));
};
const openCreateModal = () => {
dispatch(openModal());
};
const handleSearch = (e) => {
setSearchString(e.target.value);
};
const setIsBlockUser = async (user, isBlock) => {
try {
await setIsBlocked({userId: user, isBlocked: isBlock}).unwrap();
notification.success({
message: "Успех",
description: isBlock
? "Пользователь успешно заблокирован"
: "Пользователь успешно разблокирован",
placement: "topRight",
})
} catch (error) {
notification.error({
message: "Ошибка",
description: error?.data?.detail ? error?.data?.detail : isBlock
? "Не удалось заблокировать пользователя"
: "Не удалось разблокировать пользователя",
placement: "topRight",
})
}
};
const filteredUsers = useMemo(() => {
return users.filter((user) => {
return Object.entries(user).some(([key, value]) => {
if (typeof value === "string") {
return value.toString().toLowerCase().includes(searchString.toLowerCase());
}
});
});
}, [users, searchString]);
return {
roles,
isBlocking,
filteredUsers,
isLoading: isLoading || isLoadingRoles,
isError: isError || isErrorRoles,
isUpdating: false,
isUpdateError: false,
isCreating: false,
isCreateError: false,
containerStyle,
openEditModal,
openCreateModal,
setIsBlockUser,
handleSearch,
};
};
export default useUsersManageTab;

View File

@ -1,28 +1,15 @@
import {useGetAllUsersQuery} from "../../../Api/usersApi.js"; import {Grid} from "antd";
import {useGetRolesQuery} from "../../../Api/rolesApi.js";
const {useBreakpoint} = Grid;
const useAdminPage = () => { const useAdminPage = () => {
const { const screens = useBreakpoint();
data: users = [], isLoading, isError,
} = useGetAllUsersQuery(undefined, {
pollingInterval: 10000,
});
const {data: roles = [], isLoading: isLoadingRoles, isError: isErrorRoles} = useGetRolesQuery(undefined, { const containerStyle = {padding: screens.xs ? 16 : 24};
pollingInterval: 60000,
});
// const [updateUser, { isLoading: isUpdating, isError: isUpdateError }] = useUpdateUserMutation();
// const [createUser, { isLoading: isCreating, isError: isCreateError }] = useCreateUserMutation();
return { return {
users, containerStyle
roles,
isLoading: isLoading || isLoadingRoles,
isError: isError || isErrorRoles,
updateUser: () => {
}, isUpdating: false, isUpdateError: false, createUser: () => {
}, isCreating: false, isCreateError: false,
}; };
}; };

View File

@ -1,33 +0,0 @@
import {useState} from "react";
import {Grid, Form} from "antd";
import {useDispatch} from "react-redux";
import {openModal} from "../../../Redux/Slices/adminSlice.js";
import {setIsCurrentUser, setSelectedUser} from "../../../Redux/Slices/usersSlice.js";
const {useBreakpoint} = Grid;
const useAdminPageUI = () => {
const dispatch = useDispatch();
const screens = useBreakpoint();
const containerStyle = {padding: screens.xs ? 16 : 24};
const openEditModal = (user) => {
dispatch(setSelectedUser(user));
dispatch(setIsCurrentUser(true));
};
const openCreateModal = () => {
dispatch(openModal());
};
return {
containerStyle,
openEditModal,
openCreateModal,
};
};
export default useAdminPageUI;

View File

@ -7,7 +7,7 @@ import {
PlusOutlined, PlusOutlined,
ClockCircleOutlined, ClockCircleOutlined,
} from "@ant-design/icons"; } from "@ant-design/icons";
import AppointmentsCalendarTab from "./Components/AppointmentCalendarTab/AppointmentsCalendarTab.jsx"; import AppointmentsCalendar from "./Components/AppointmentCalendar/AppointmentsCalendar.jsx";
import useAppointments from "./useAppointments.js"; import useAppointments from "./useAppointments.js";
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import LoadingIndicator from "../../Widgets/LoadingIndicator/LoadingIndicator.jsx"; import LoadingIndicator from "../../Widgets/LoadingIndicator/LoadingIndicator.jsx";
@ -19,9 +19,8 @@ import AppointmentsListModal from "./Components/AppointmentsListModal/Appointmen
const AppointmentsPage = () => { const AppointmentsPage = () => {
const { const {
patients, // Добавляем appointments,
appointments, // Добавляем scheduledAppointments,
scheduledAppointments, // Добавляем
isLoading, isLoading,
isError, isError,
collapsed, collapsed,
@ -87,7 +86,7 @@ const AppointmentsPage = () => {
min="25%" min="25%"
max="90%" max="90%"
> >
<AppointmentsCalendarTab <AppointmentsCalendar
currentMonth={currentMonth} currentMonth={currentMonth}
onMonthChange={handleMonthChange} onMonthChange={handleMonthChange}
appointments={appointments} // Добавляем appointments={appointments} // Добавляем

View File

@ -1,13 +1,13 @@
import {Calendar} from "antd"; import {Calendar} from "antd";
import "dayjs/locale/ru"; import "dayjs/locale/ru";
import CalendarCell from "../CalendarCell/CalendarCell.jsx"; import CalendarCell from "../CalendarCell/CalendarCell.jsx";
import useAppointmentCalendarUI from "./useAppointmentCalendarUI.js"; import useAppointmentCalendar from "./useAppointmentCalendar.js";
import AppointmentsListModal from "../AppointmentsListModal/AppointmentsListModal.jsx"; import AppointmentsListModal from "../AppointmentsListModal/AppointmentsListModal.jsx";
import dayjs from "dayjs"; import dayjs from "dayjs";
import PropTypes from "prop-types"; import PropTypes from "prop-types";
const AppointmentsCalendarTab = ({currentMonth, onMonthChange, appointments, scheduledAppointments}) => { const AppointmentsCalendar = ({currentMonth, onMonthChange, appointments, scheduledAppointments}) => {
const appointmentsCalendarUI = useAppointmentCalendarUI(appointments, scheduledAppointments); const appointmentsCalendarUI = useAppointmentCalendar(appointments, scheduledAppointments);
const dateCellRender = (value) => { const dateCellRender = (value) => {
const appointmentsForDate = appointmentsCalendarUI.getAppointmentsByListAndDate( const appointmentsForDate = appointmentsCalendarUI.getAppointmentsByListAndDate(
@ -39,12 +39,11 @@ const AppointmentsCalendarTab = ({currentMonth, onMonthChange, appointments, sch
<div style={appointmentsCalendarUI.calendarContainerStyle}> <div style={appointmentsCalendarUI.calendarContainerStyle}>
<Calendar <Calendar
fullscreen={appointmentsCalendarUI.fullScreenCalendar} fullscreen={appointmentsCalendarUI.fullScreenCalendar}
value={currentMonth} // Используем currentMonth вместо selectedDate value={currentMonth}
onSelect={appointmentsCalendarUI.onSelect}
onPanelChange={(value, mode) => { onPanelChange={(value, mode) => {
appointmentsCalendarUI.onPanelChange(value, mode); appointmentsCalendarUI.onPanelChange(value, mode);
if (mode === "month") { if (mode === "month") {
onMonthChange(value); // Вызываем onMonthChange при смене месяца onMonthChange(value);
} }
}} }}
cellRender={dateCellRender} cellRender={dateCellRender}
@ -54,11 +53,11 @@ const AppointmentsCalendarTab = ({currentMonth, onMonthChange, appointments, sch
); );
}; };
AppointmentsCalendarTab.propTypes = { AppointmentsCalendar.propTypes = {
currentMonth: PropTypes.object.isRequired, currentMonth: PropTypes.object.isRequired,
onMonthChange: PropTypes.func.isRequired, onMonthChange: PropTypes.func.isRequired,
appointments: PropTypes.array.isRequired, appointments: PropTypes.array.isRequired,
scheduledAppointments: PropTypes.array.isRequired, scheduledAppointments: PropTypes.array.isRequired,
}; };
export default AppointmentsCalendarTab; export default AppointmentsCalendar;

View File

@ -17,7 +17,7 @@ dayjs.tz.setDefault("Europe/Moscow");
const { useBreakpoint } = Grid; const { useBreakpoint } = Grid;
const useAppointmentCalendarUI = (appointments, scheduledAppointments) => { const useAppointmentCalendar = (appointments, scheduledAppointments) => {
const dispatch = useDispatch(); const dispatch = useDispatch();
const selectedDate = dayjs.tz(useSelector((state) => state.appointmentsUI.selectedDate), "Europe/Moscow"); const selectedDate = dayjs.tz(useSelector((state) => state.appointmentsUI.selectedDate), "Europe/Moscow");
@ -75,4 +75,4 @@ const useAppointmentCalendarUI = (appointments, scheduledAppointments) => {
}; };
}; };
export default useAppointmentCalendarUI; export default useAppointmentCalendar;

View File

@ -2,7 +2,7 @@ import {Badge, Col, Tag, Tooltip} from "antd";
import PropTypes from "prop-types"; import PropTypes from "prop-types";
import {AppointmentPropType} from "../../../../../Types/appointmentPropType.js"; import {AppointmentPropType} from "../../../../../Types/appointmentPropType.js";
import {ScheduledAppointmentPropType} from "../../../../../Types/scheduledAppointmentPropType.js"; import {ScheduledAppointmentPropType} from "../../../../../Types/scheduledAppointmentPropType.js";
import useCalendarCellUI from "./useCalendarCellUI.js"; import useCalendarCell from "./useCalendarCell.js";
const CalendarCell = ({allAppointments, onCellClick, onItemClick}) => { const CalendarCell = ({allAppointments, onCellClick, onItemClick}) => {
const { const {
@ -18,7 +18,7 @@ const CalendarCell = ({allAppointments, onCellClick, onItemClick}) => {
getBadgeText, getBadgeText,
getTagColor, getTagColor,
getBadgeStatus, getBadgeStatus,
} = useCalendarCellUI(); } = useCalendarCell();
return ( return (
<div <div

View File

@ -1,7 +1,7 @@
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import dayjs from "dayjs"; import dayjs from "dayjs";
const useCalendarCellUI = () => { const useCalendarCell = () => {
const containerRef = useRef(null); const containerRef = useRef(null);
const [isCompressed, setIsCompressed] = useState(false); const [isCompressed, setIsCompressed] = useState(false);
const COMPRESSION_THRESHOLD = 70; const COMPRESSION_THRESHOLD = 70;
@ -53,7 +53,7 @@ const useCalendarCellUI = () => {
const compressedCountStyle = { const compressedCountStyle = {
position: "absolute", position: "absolute",
top: 2, bottom: 12,
right: 2, right: 2,
fontSize: 10, fontSize: 10,
fontWeight: "bold", fontWeight: "bold",
@ -108,4 +108,4 @@ const useCalendarCellUI = () => {
}; };
}; };
export default useCalendarCellUI; export default useCalendarCell;

View File

@ -1,12 +1,12 @@
import { useEffect, useMemo, useState } from "react"; import {useEffect, useMemo, useState} from "react";
import { useDispatch, useSelector } from "react-redux"; import {useDispatch, useSelector} from "react-redux";
import { notification } from "antd"; import {notification} from "antd";
import { Grid } from "antd"; import {Grid} from "antd";
import { import {
useGetAppointmentsQuery, useGetAppointmentsQuery,
useGetUpcomingAppointmentsQuery, useGetUpcomingAppointmentsQuery,
} from "../../../Api/appointmentsApi.js"; } from "../../../Api/appointmentsApi.js";
import { useGetAllPatientsQuery } from "../../../Api/patientsApi.js"; import {useGetAllPatientsQuery} from "../../../Api/patientsApi.js";
import { import {
openModal, openModal,
openScheduledModal, openScheduledModal,
@ -26,12 +26,12 @@ import timezone from "dayjs/plugin/timezone";
dayjs.extend(utc); dayjs.extend(utc);
dayjs.extend(timezone); dayjs.extend(timezone);
const { useBreakpoint } = Grid; const {useBreakpoint} = Grid;
const useAppointments = () => { const useAppointments = () => {
const dispatch = useDispatch(); const dispatch = useDispatch();
const { userData } = useSelector(state => state.auth); const {userData} = useSelector(state => state.auth);
const { collapsed, siderWidth, hovered, selectedAppointment } = useSelector(state => state.appointmentsUI); const {collapsed, siderWidth, hovered, selectedAppointment} = useSelector(state => state.appointmentsUI);
const screens = useBreakpoint(); const screens = useBreakpoint();
const [currentMonth, setCurrentMonth] = useState(dayjs().startOf('month')); const [currentMonth, setCurrentMonth] = useState(dayjs().startOf('month'));
@ -47,7 +47,7 @@ const useAppointments = () => {
data: appointments = [], data: appointments = [],
isLoading: isLoadingAppointments, isLoading: isLoadingAppointments,
isError: isErrorAppointments, isError: isErrorAppointments,
} = useGetAppointmentsQuery({ doctor_id: userData.id, start_date: startDate, end_date: endDate }, { } = useGetAppointmentsQuery({doctor_id: userData.id, start_date: startDate, end_date: endDate}, {
pollingInterval: 60000, pollingInterval: 60000,
skip: !userData.id, skip: !userData.id,
}); });
@ -56,7 +56,7 @@ const useAppointments = () => {
data: scheduledAppointments = [], data: scheduledAppointments = [],
isLoading: isLoadingScheduledAppointments, isLoading: isLoadingScheduledAppointments,
isError: isErrorScheduledAppointments, isError: isErrorScheduledAppointments,
} = useGetScheduledAppointmentsQuery({ doctor_id: userData.id, start_date: startDate, end_date: endDate }, { } = useGetScheduledAppointmentsQuery({doctor_id: userData.id, start_date: startDate, end_date: endDate}, {
pollingInterval: 60000, pollingInterval: 60000,
skip: !userData.id, skip: !userData.id,
}); });
@ -90,10 +90,10 @@ const useAppointments = () => {
const [localSiderWidth, setLocalSiderWidth] = useState(siderWidth); const [localSiderWidth, setLocalSiderWidth] = useState(siderWidth);
const splitterStyle = { flex: 1 }; const splitterStyle = {flex: 1};
const splitterContentPanelStyle = { padding: 16 }; const splitterContentPanelStyle = {padding: 16};
const splitterSiderPanelStyle = { padding: "16px", borderLeft: "1px solid #ddd", overflowY: "auto" }; const splitterSiderPanelStyle = {padding: "16px", borderLeft: "1px solid #ddd", overflowY: "auto"};
const siderTitleStyle = { marginBottom: 36 }; const siderTitleStyle = {marginBottom: 36};
const siderButtonContainerStyle = { const siderButtonContainerStyle = {
position: "fixed", position: "fixed",
right: 0, right: 0,

View File

@ -1,11 +1,9 @@
import {Form, Input, Button, Row, Col, Typography, Image, Space} from "antd"; import { Form, Input, Button, Row, Col, Typography, Image, Space } from "antd";
import useLoginPage from "./useLoginPage.js"; import useLoginPage from "./useLoginPage.js";
import useLoginPageUI from "./useLoginPageUI.js";
const {Title} = Typography; const { Title } = Typography;
const LoginPage = () => { const LoginPage = () => {
const {onFinish, isLoading} = useLoginPage();
const { const {
containerStyle, containerStyle,
formContainerStyle, formContainerStyle,
@ -13,8 +11,10 @@ const LoginPage = () => {
logoBlockStyle, logoBlockStyle,
logoStyle, logoStyle,
appNameStyle, appNameStyle,
labels labels,
} = useLoginPageUI(); isLoading,
onFinish,
} = useLoginPage();
return ( return (
<Row justify="center" align="middle" style={containerStyle}> <Row justify="center" align="middle" style={containerStyle}>
@ -35,19 +35,19 @@ const LoginPage = () => {
{labels.title} {labels.title}
</Title> </Title>
<Form name="login" initialValues={{remember: true}} onFinish={onFinish}> <Form name="login" initialValues={{ remember: true }} onFinish={onFinish}>
<Form.Item <Form.Item
name="login" name="login"
rules={[{required: true, message: labels.loginRequired}]} rules={[{ required: true, message: labels.loginRequired }]}
> >
<Input placeholder={labels.loginPlaceholder}/> <Input placeholder={labels.loginPlaceholder} />
</Form.Item> </Form.Item>
<Form.Item <Form.Item
name="password" name="password"
rules={[{required: true, message: labels.passwordRequired}]} rules={[{ required: true, message: labels.passwordRequired }]}
> >
<Input.Password placeholder={labels.passwordPlaceholder}/> <Input.Password placeholder={labels.passwordPlaceholder} />
</Form.Item> </Form.Item>
<Form.Item> <Form.Item>

View File

@ -1,12 +1,73 @@
import { useDispatch } from "react-redux"; import { useEffect, useRef } from "react";
import { notification } from "antd"; import { useNavigate } from "react-router-dom";
import { useDispatch, useSelector } from "react-redux";
import { Grid, notification } from "antd";
import { setError, setUser } from "../../../Redux/Slices/authSlice.js"; import { setError, setUser } from "../../../Redux/Slices/authSlice.js";
import { useLoginMutation } from "../../../Api/authApi.js"; import { useLoginMutation } from "../../../Api/authApi.js";
import { checkAuth } from "../../../Redux/Slices/authSlice.js"; import { checkAuth } from "../../../Redux/Slices/authSlice.js";
const { useBreakpoint } = Grid;
const useLoginPage = () => { const useLoginPage = () => {
const navigate = useNavigate();
const dispatch = useDispatch(); const dispatch = useDispatch();
const [loginUser, { isLoading }] = useLoginMutation(); const [loginUser, { isLoading }] = useLoginMutation();
const { user, userData } = useSelector((state) => state.auth);
const screens = useBreakpoint();
const hasRedirected = useRef(false);
const containerStyle = {
minHeight: "100vh",
};
const formContainerStyle = {
padding: screens.xs ? 10 : 20,
border: "1px solid #ddd",
borderRadius: 8,
textAlign: "center",
};
const titleStyle = {
textAlign: "center",
marginBottom: 20,
};
const logoStyle = {
width: 80,
marginBottom: 10,
borderRadius: 20,
border: "1px solid #ddd",
};
const appNameStyle = {
textAlign: "center",
color: "#1890ff",
marginBottom: 40,
};
const logoBlockStyle = {
display: "flex",
flexDirection: "row",
alignItems: "center",
justifyContent: "center",
};
const labels = {
title: "Авторизация",
loginPlaceholder: "Логин",
passwordPlaceholder: "Пароль",
submitButton: "Войти",
loginRequired: "Пожалуйста, введите логин",
passwordRequired: "Пожалуйста, введите пароль",
};
useEffect(() => {
if (user && userData && !isLoading && !hasRedirected.current) {
hasRedirected.current = true;
navigate("/");
}
document.title = labels.title;
}, [user, userData, isLoading, navigate]);
const onFinish = async (loginData) => { const onFinish = async (loginData) => {
try { try {
@ -32,6 +93,13 @@ const useLoginPage = () => {
}; };
return { return {
containerStyle,
formContainerStyle,
titleStyle,
logoStyle,
appNameStyle,
labels,
logoBlockStyle,
onFinish, onFinish,
isLoading, isLoading,
}; };

View File

@ -1,78 +0,0 @@
import { useEffect, useRef } from "react";
import { useNavigate } from "react-router-dom";
import { useSelector } from "react-redux";
import { Grid } from "antd";
const { useBreakpoint } = Grid;
const useLoginPageUI = () => {
const navigate = useNavigate();
const { user, userData, isLoading } = useSelector((state) => state.auth);
const screens = useBreakpoint();
const hasRedirected = useRef(false);
const containerStyle = {
minHeight: "100vh",
};
const formContainerStyle = {
padding: screens.xs ? 10 : 20,
border: "1px solid #ddd",
borderRadius: 8,
textAlign: "center",
};
const titleStyle = {
textAlign: "center",
marginBottom: 20,
};
const logoStyle = {
width: 80,
marginBottom: 10,
borderRadius: 20,
border: "1px solid #ddd",
};
const appNameStyle = {
textAlign: "center",
color: "#1890ff",
marginBottom: 40,
};
const logoBlockStyle = {
display: "flex",
flexDirection: "row",
alignItems: "center",
justifyContent: "center",
};
const labels = {
title: "Авторизация",
loginPlaceholder: "Логин",
passwordPlaceholder: "Пароль",
submitButton: "Войти",
loginRequired: "Пожалуйста, введите логин",
passwordRequired: "Пожалуйста, введите пароль",
};
useEffect(() => {
if (user && userData && !isLoading && !hasRedirected.current) {
hasRedirected.current = true;
navigate("/");
}
document.title = labels.title;
}, [user, userData, isLoading, navigate]);
return {
containerStyle,
formContainerStyle,
titleStyle,
logoStyle,
appNameStyle,
labels,
logoBlockStyle,
};
};
export default useLoginPageUI;

View File

@ -150,7 +150,7 @@ const PatientsPage = () => {
{patientsData.isLoading ? <LoadingIndicator/> : patientsData.viewMode === "tile" ? ( {patientsData.isLoading ? <LoadingIndicator/> : patientsData.viewMode === "tile" ? (
<List <List
grid={{gutter: 16, column: 3}} grid={{ gutter: 16, xs: 1, sm: 1, md: 2, lg: 3, xl: 4 }}
dataSource={patientsData.filteredPatients} dataSource={patientsData.filteredPatients}
renderItem={patient => ( renderItem={patient => (
<List.Item> <List.Item>

View File

@ -2,22 +2,19 @@ import {Button, Card, Col, Row, Typography, Result} from "antd";
import {EditOutlined, UserOutlined} from "@ant-design/icons"; import {EditOutlined, UserOutlined} from "@ant-design/icons";
import LoadingIndicator from "../../Widgets/LoadingIndicator/LoadingIndicator.jsx"; import LoadingIndicator from "../../Widgets/LoadingIndicator/LoadingIndicator.jsx";
import useProfilePage from "./useProfilePage.js"; import useProfilePage from "./useProfilePage.js";
import useProfilePageUI from "./useProfilePageUI.js";
import UpdateUserModalForm from "../../Dummies/UpdateUserModalForm/UpdateUserModalForm.jsx"; import UpdateUserModalForm from "../../Dummies/UpdateUserModalForm/UpdateUserModalForm.jsx";
const ProfilePage = () => { const ProfilePage = () => {
const {
userData,
isLoading,
isError,
} = useProfilePage();
const { const {
containerStyle, containerStyle,
cardStyle, cardStyle,
buttonStyle, buttonStyle,
isLoading,
isError,
userData,
handleEditUser, handleEditUser,
} = useProfilePageUI(userData); } = useProfilePage();
if (isError) { if (isError) {
return ( return (

View File

@ -1,8 +1,14 @@
import { import {Grid} from "antd";
useGetAuthenticatedUserDataQuery, import {useDispatch} from "react-redux";
} from "../../../Api/usersApi.js"; import {setSelectedUser} from "../../../Redux/Slices/usersSlice.js";
import {useGetAuthenticatedUserDataQuery} from "../../../Api/usersApi.js";
const {useBreakpoint} = Grid;
const useProfilePage = () => { const useProfilePage = () => {
const dispatch = useDispatch();
const screens = useBreakpoint();
const { const {
data: userData = {}, data: userData = {},
isLoading: isLoadingUserData, isLoading: isLoadingUserData,
@ -11,10 +17,24 @@ const useProfilePage = () => {
pollingInterval: 20000, pollingInterval: 20000,
}); });
const containerStyle = {padding: screens.xs ? 16 : 24};
const cardStyle = {marginBottom: 24};
const buttonStyle = {width: screens.xs ? "100%" : "auto"};
const handleEditUser = () => {
dispatch(setSelectedUser(userData))
};
return { return {
userData, userData,
containerStyle,
cardStyle,
buttonStyle,
isMobile: screens.xs,
isLoading: isLoadingUserData, isLoading: isLoadingUserData,
isError: isErrorUserData, isError: isErrorUserData,
handleEditUser,
}; };
}; };

View File

@ -1,29 +0,0 @@
import {Grid} from "antd";
import {useDispatch} from "react-redux";
import {setSelectedUser} from "../../../Redux/Slices/usersSlice.js";
const { useBreakpoint } = Grid;
const useProfilePageUI = (userData) => {
const dispatch = useDispatch();
const screens = useBreakpoint();
const containerStyle = { padding: screens.xs ? 16 : 24 };
const cardStyle = { marginBottom: 24 };
const buttonStyle = { width: screens.xs ? "100%" : "auto" };
const handleEditUser = () => {
dispatch(setSelectedUser(userData))
};
return {
containerStyle,
cardStyle,
buttonStyle,
isMobile: screens.xs,
handleEditUser,
};
};
export default useProfilePageUI;

View File

@ -1,9 +1,7 @@
import {Button, Modal, Popconfirm, Row, Typography} from "antd"; import {Button, Modal, Popconfirm, Row, Typography} from "antd";
import useScheduledAppointmentsViewModal from "./useScheduledAppointmentsViewModal.js"; import useScheduledAppointmentsViewModal from "./useScheduledAppointmentsViewModal.js";
import useScheduledAppointmentsViewModalUI from "./useScheduledAppointmentsViewModalUI.js";
const ScheduledAppointmentsViewModal = () => { const ScheduledAppointmentsViewModal = () => {
const scheduledAppointmentsViewModalData = useScheduledAppointmentsViewModal();
const { const {
selectedScheduledAppointment, selectedScheduledAppointment,
modalWidth, modalWidth,
@ -19,7 +17,7 @@ const ScheduledAppointmentsViewModal = () => {
onCancel, onCancel,
cancelScheduledAppointment, cancelScheduledAppointment,
handleConvertToAppointment, handleConvertToAppointment,
} = useScheduledAppointmentsViewModalUI(scheduledAppointmentsViewModalData.cancelAppointment); } = useScheduledAppointmentsViewModal();
if (!selectedScheduledAppointment) { if (!selectedScheduledAppointment) {
return null; return null;

View File

@ -1,10 +1,110 @@
import { useDispatch, useSelector } from "react-redux";
import { setSelectedScheduledAppointment, openModalWithScheduledData } from "../../../Redux/Slices/appointmentsSlice.js";
import { notification } from "antd";
import dayjs from "dayjs";
import {useCancelScheduledAppointmentMutation} from "../../../Api/scheduledAppointmentsApi.js"; import {useCancelScheduledAppointmentMutation} from "../../../Api/scheduledAppointmentsApi.js";
const useScheduledAppointmentsViewModal = () => { const useScheduledAppointmentsViewModal = () => {
const dispatch = useDispatch();
const { selectedScheduledAppointment } = useSelector((state) => state.appointmentsUI);
const [cancelAppointment] = useCancelScheduledAppointmentMutation(); const [cancelAppointment] = useCancelScheduledAppointmentMutation();
return {cancelAppointment};
const modalWidth = 700;
const blockStyle = { marginBottom: 16 };
const footerRowStyle = { marginTop: 16, gap: 8 };
const footerButtonStyle = { marginRight: 8 };
const labels = {
title: "Просмотр запланированного приема",
patient: "Пациент:",
birthday: "Дата рождения:",
email: "Email:",
phone: "Телефон:",
type: "Тип приема:",
appointmentTime: "Время приема:",
closeButton: "Закрыть",
convertButton: "Конвертировать в прием",
cancelButton: "Отмена приема",
popconfirmTitle: "Вы уверены, что хотите отменить прием?",
popconfirmOk: "Да, отменить",
popconfirmCancel: "Отмена",
notSpecified: "Не указан",
};
const visible = !!selectedScheduledAppointment;
const getDateString = (date) => {
return date ? dayjs(date).format("DD.MM.YYYY") : labels.notSpecified;
};
const getAppointmentTime = (datetime) => {
return datetime
? dayjs(datetime).format("DD.MM.YYYY HH:mm")
: labels.notSpecified;
};
const getPatientName = (patient) => {
return patient
? `${patient.last_name} ${patient.first_name}`
: labels.notSpecified;
};
const getPatientField = (field) => {
return field || labels.notSpecified;
};
const onCancel = () => {
dispatch(setSelectedScheduledAppointment(null));
};
const cancelScheduledAppointment = async () => {
try {
await cancelAppointment(selectedScheduledAppointment.id);
notification.success({
message: "Прием отменен",
placement: "topRight",
description: "Прием успешно отменен.",
});
onCancel();
} catch (error) {
notification.error({
message: "Ошибка",
description: error?.data?.detail || "Не удалось отменить прием.",
placement: "topRight",
});
}
};
const handleConvertToAppointment = () => {
if (selectedScheduledAppointment) {
dispatch(
openModalWithScheduledData({
id: selectedScheduledAppointment.id,
patient_id: selectedScheduledAppointment.patient?.id,
type_id: selectedScheduledAppointment.type?.id,
appointment_datetime: selectedScheduledAppointment.scheduled_datetime,
})
);
}
};
return {
selectedScheduledAppointment,
modalWidth,
blockStyle,
footerRowStyle,
footerButtonStyle,
labels,
visible,
getDateString,
getAppointmentTime,
getPatientName,
getPatientField,
onCancel,
cancelScheduledAppointment,
handleConvertToAppointment,
};
}; };
export default useScheduledAppointmentsViewModal; export default useScheduledAppointmentsViewModal;

View File

@ -1,107 +0,0 @@
import { useDispatch, useSelector } from "react-redux";
import { setSelectedScheduledAppointment, openModalWithScheduledData } from "../../../Redux/Slices/appointmentsSlice.js";
import { notification } from "antd";
import dayjs from "dayjs";
const useScheduledAppointmentsViewModalUI = (cancelAppointment) => {
const dispatch = useDispatch();
const { selectedScheduledAppointment } = useSelector((state) => state.appointmentsUI);
const modalWidth = 700;
const blockStyle = { marginBottom: 16 };
const footerRowStyle = { marginTop: 16, gap: 8 };
const footerButtonStyle = { marginRight: 8 };
const labels = {
title: "Просмотр запланированного приема",
patient: "Пациент:",
birthday: "Дата рождения:",
email: "Email:",
phone: "Телефон:",
type: "Тип приема:",
appointmentTime: "Время приема:",
closeButton: "Закрыть",
convertButton: "Конвертировать в прием",
cancelButton: "Отмена приема",
popconfirmTitle: "Вы уверены, что хотите отменить прием?",
popconfirmOk: "Да, отменить",
popconfirmCancel: "Отмена",
notSpecified: "Не указан",
};
const visible = !!selectedScheduledAppointment;
const getDateString = (date) => {
return date ? dayjs(date).format("DD.MM.YYYY") : labels.notSpecified;
};
const getAppointmentTime = (datetime) => {
return datetime
? dayjs(datetime).format("DD.MM.YYYY HH:mm")
: labels.notSpecified;
};
const getPatientName = (patient) => {
return patient
? `${patient.last_name} ${patient.first_name}`
: labels.notSpecified;
};
const getPatientField = (field) => {
return field || labels.notSpecified;
};
const onCancel = () => {
dispatch(setSelectedScheduledAppointment(null));
};
const cancelScheduledAppointment = async () => {
try {
await cancelAppointment(selectedScheduledAppointment.id);
notification.success({
message: "Прием отменен",
placement: "topRight",
description: "Прием успешно отменен.",
});
onCancel();
} catch (error) {
notification.error({
message: "Ошибка",
description: error?.data?.detail || "Не удалось отменить прием.",
placement: "topRight",
});
}
};
const handleConvertToAppointment = () => {
if (selectedScheduledAppointment) {
dispatch(
openModalWithScheduledData({
id: selectedScheduledAppointment.id,
patient_id: selectedScheduledAppointment.patient?.id,
type_id: selectedScheduledAppointment.type?.id,
appointment_datetime: selectedScheduledAppointment.scheduled_datetime,
})
);
}
};
return {
selectedScheduledAppointment,
modalWidth,
blockStyle,
footerRowStyle,
footerButtonStyle,
labels,
visible,
getDateString,
getAppointmentTime,
getPatientName,
getPatientField,
onCancel,
cancelScheduledAppointment,
handleConvertToAppointment,
};
};
export default useScheduledAppointmentsViewModalUI;

View File

@ -1,12 +1,12 @@
import { Select, Tooltip } from "antd"; import { Select, Tooltip } from "antd";
import PropTypes from "prop-types"; import PropTypes from "prop-types";
import { ViewModPropType } from "../../../Types/viewModPropType.js"; import { ViewModPropType } from "../../../Types/viewModPropType.js";
import useSelectViewModeUI from "./useSelectViewModeUI.js"; import useSelectViewMode from "./useSelectViewMode.js";
const { Option } = Select; const { Option } = Select;
const SelectViewMode = ({ viewMode, setViewMode, localStorageKey, toolTipText, viewModes }) => { const SelectViewMode = ({ viewMode, setViewMode, localStorageKey, toolTipText, viewModes }) => {
const { selectStyle, handleChange } = useSelectViewModeUI({ setViewMode, localStorageKey }); const { selectStyle, handleChange } = useSelectViewMode({ setViewMode, localStorageKey });
return ( return (
<Tooltip title={toolTipText}> <Tooltip title={toolTipText}>

View File

@ -1,6 +1,6 @@
import { cacheInfo } from "../../../Utils/cachedInfoUtils.js"; import { cacheInfo } from "../../../Utils/cachedInfoUtils.js";
const useSelectViewModeUI = ({ setViewMode, localStorageKey }) => { const useSelectViewMode = ({ setViewMode, localStorageKey }) => {
const selectStyle = { const selectStyle = {
width: "100%", width: "100%",
}; };
@ -16,4 +16,4 @@ const useSelectViewModeUI = ({ setViewMode, localStorageKey }) => {
}; };
}; };
export default useSelectViewModeUI; export default useSelectViewMode;

View File

@ -9,10 +9,4 @@ const getCachedInfo = (key) => {
return JSON.parse(data); return JSON.parse(data);
}; };
const getCacheTimestamp = (key) => { export {cacheInfo, getCachedInfo};
const timestamp = localStorage.getItem(`${key}Timestamp`);
if (!timestamp) return null;
return parseInt(timestamp);
};
export {cacheInfo, getCachedInfo, getCacheTimestamp};