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
# 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 = (
select(User)
.options(joinedload(User.role))
.order_by(User.id)
)
result = await self.db.execute(stmt)
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)
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
patronymic: Optional[str] = None
login: str
is_blocked: Optional[bool] = 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.appointments import Appointment
from app.domain.models.backups import Backup
from app.domain.models.appointment_types import AppointmentType
from app.domain.models.lens_types import LensType
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 werkzeug.security import check_password_hash, generate_password_hash
@ -13,6 +13,7 @@ class User(BaseModel):
patronymic = Column(VARCHAR(200))
login = Column(String, nullable=False, unique=True)
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)

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:
payload = jwt.decode(credentials.credentials, auth_data["secret_key"], algorithms=[auth_data["algorithm"]])
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:
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")
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)
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

View File

@ -122,6 +122,33 @@ class UsersService:
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]:
user_model = await self.users_repository.get_by_id(user_id)
if not user_model:
@ -180,6 +207,7 @@ class UsersService:
last_name=user.last_name,
patronymic=user.patronymic,
login=user.login,
is_blocked=user.is_blocked,
)
if user.id is not None:
@ -196,4 +224,5 @@ class UsersService:
patronymic=user.patronymic,
login=user.login,
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.appointments_router import router as appointment_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_types_router import router as lens_types_router
from app.controllers.lenses_router import router as lenses_router
@ -35,6 +36,7 @@ def start_app():
tags=['appointment_types'])
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(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_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'])

View File

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

View File

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

View File

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

View File

@ -1,10 +1,10 @@
import { fetchBaseQuery } from '@reduxjs/toolkit/query/react';
import { logout } from '../Redux/Slices/authSlice.js';
import {fetchBaseQuery} from '@reduxjs/toolkit/query/react';
import {logout} from '../Redux/Slices/authSlice.js';
import CONFIG from "../Core/сonfig.js";
export const baseQuery = fetchBaseQuery({
baseUrl: CONFIG.BASE_URL,
prepareHeaders: (headers, { getState, endpoint }) => {
prepareHeaders: (headers, {getState, endpoint}) => {
const token = localStorage.getItem('access_token');
if (token) {
headers.set('Authorization', `Bearer ${token}`);
@ -25,7 +25,7 @@ export const baseQuery = fetchBaseQuery({
export const baseQueryWithAuth = async (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');
api.dispatch(logout());
window.location.href = '/login';

View File

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

View File

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

View File

@ -32,6 +32,13 @@ export const usersApi = createApi({
}),
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,
useUpdateUserMutation,
useGetAllUsersQuery,
useSetIsBlockedMutation
} = usersApi;

View File

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

View File

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

View File

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

View File

@ -422,6 +422,7 @@ const useAppointmentFormModalUI = (createAppointment, patients, cancelAppointmen
screenXS,
direction,
isDrawerVisible,
formValues,
showDrawer,
closeDrawer,
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 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 {
HomeOutlined,
@ -11,34 +11,30 @@ import {
MessageOutlined, ControlOutlined
} from "@ant-design/icons";
import useMainLayout from "./useMainLayout.js";
import useMainLayoutUI from "./useMainLayoutUI.js";
import LoadingIndicator from "../Widgets/LoadingIndicator/LoadingIndicator.jsx";
const {Content, Footer, Sider} = Layout;
const MainLayout = () => {
const mainLayoutData = useMainLayout();
const mainLayoutUI = useMainLayoutUI(mainLayoutData.user);
const menuItems = [
mainLayoutUI.getItem("Главная", "/", <HomeOutlined />),
mainLayoutUI.getItem("Приёмы", "/appointments", <CalendarOutlined />),
mainLayoutUI.getItem("Выдачи линз", "/issues", <DatabaseOutlined />),
mainLayoutUI.getItem("Линзы и наборы", "/Lenses", <FolderViewOutlined />),
mainLayoutUI.getItem("Пациенты", "/Patients", <TeamOutlined />),
mainLayoutUI.getItem("Рассылки", "/mailing", <MessageOutlined />),
{ type: "divider" }
mainLayoutData.getItem("Главная", "/", <HomeOutlined/>),
mainLayoutData.getItem("Приёмы", "/appointments", <CalendarOutlined/>),
mainLayoutData.getItem("Выдачи линз", "/issues", <DatabaseOutlined/>),
mainLayoutData.getItem("Линзы и наборы", "/Lenses", <FolderViewOutlined/>),
mainLayoutData.getItem("Пациенты", "/Patients", <TeamOutlined/>),
mainLayoutData.getItem("Рассылки", "/mailing", <MessageOutlined/>),
{type: "divider"}
];
if (mainLayoutData.user?.role.title === "Администратор") {
menuItems.push(mainLayoutUI.getItem("Панель администратора", "/admin", <ControlOutlined />));
menuItems.push(mainLayoutData.getItem("Панель администратора", "/admin", <ControlOutlined/>));
}
menuItems.push(
mainLayoutUI.getItem("Мой профиль", "profile", <UserOutlined />, [
mainLayoutUI.getItem("Перейти в профиль", "/profile", <UserOutlined />),
mainLayoutUI.getItem("Выйти", "logout", <LogoutOutlined />)
])
mainLayoutData.getItem("Перейти в профиль", "/profile", <UserOutlined/>),
mainLayoutData.getItem("Выйти", "logout", <LogoutOutlined/>)
);
if (mainLayoutData.isUserError) {
@ -47,48 +43,101 @@ const MainLayout = () => {
return (
<Layout style={{minHeight: "100vh"}}>
<Sider
collapsible={!mainLayoutUI.screens.xs}
collapsed={mainLayoutUI.collapsed}
onCollapse={mainLayoutUI.setCollapsed}
style={{height: "100vh", position: "fixed", left: 0}}
>
<div style={{display: "flex", justifyContent: "center", padding: 16}}>
<img
src="/logo_rounded.png"
alt="Логотип"
style={{width: mainLayoutUI.collapsed ? 40 : 80, transition: "width 0.2s"}}
/>
</div>
<Menu
theme="dark"
selectedKeys={[location.pathname]}
mode="inline"
items={menuItems}
onClick={mainLayoutUI.handleMenuClick}
/>
</Sider>
{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
collapsible={!mainLayoutData.screens.xs}
collapsed={mainLayoutData.collapsed}
onCollapse={mainLayoutData.setCollapsed}
style={{height: "100vh", position: "fixed", left: 0, overflow: "auto"}}
>
<div style={{display: "flex", justifyContent: "center", padding: 16}}>
<img
src="/logo_rounded.png"
alt="Логотип"
style={{width: mainLayoutData.collapsed ? 40 : 80, transition: "width 0.2s"}}
/>
</div>
<Menu
theme="dark"
selectedKeys={[location.pathname]}
mode="inline"
items={menuItems}
onClick={mainLayoutData.handleMenuClick}
/>
</Sider>
<Layout
style={{marginLeft: mainLayoutData.collapsed ? 80 : 200, transition: "margin-left 0.2s"}}
>
<Content style={{
margin: "0 16px",
padding: 24,
minHeight: "100vh",
overflow: "auto",
background: "#fff",
borderRadius: 8,
marginTop: "15px"
}}>
{mainLayoutData.isUserLoading ? (
<LoadingIndicator/>
) : (
<Outlet/>
)}
</Content>
<Footer style={{textAlign: "center"}}>Visus+ © {new Date().getFullYear()}</Footer>
</Layout>
</>
)}
<Layout
style={{marginLeft: mainLayoutUI.collapsed ? 80 : 200, transition: "margin-left 0.2s"}}
>
<Content style={{
margin: "0 16px",
padding: 24,
minHeight: "100vh",
overflow: "auto",
background: "#fff",
borderRadius: 8,
marginTop: "15px"
}}>
{mainLayoutData.isUserLoading ? (
<LoadingIndicator/>
) : (
<Outlet/>
)}
</Content>
<Footer style={{textAlign: "center"}}>Visus+ © {new Date().getFullYear()}</Footer>
</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 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);
return {
user,
isUserLoading,
isUserError,
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, user, isUserLoading, isUserError, handleMenuClick, getItem};
};
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 {ControlOutlined} from "@ant-design/icons";
import LoadingIndicator from "../../Widgets/LoadingIndicator/LoadingIndicator.jsx";
import {Tabs, Typography} from "antd";
import {
ContainerOutlined,
ControlOutlined,
UserOutlined
} from "@ant-design/icons";
import useAdminPage from "./useAdminPage.js";
import useAdminPageUI from "./useAdminPageUI.js";
import CreateUserModalForm from "./Components/CreateUserModalForm/CreateUserModalForm.jsx";
import UpdateUserModalForm from "../../Dummies/UpdateUserModalForm/UpdateUserModalForm.jsx";
import UsersManageTab from "./Components/UsersManageTab/UsersManageTab.jsx";
import BackupManageTab from "./Components/BackupManageTab/BackupManageTab.jsx";
const items = [
{
key: '1',
label: 'Пользователи',
children: <UsersManageTab/>,
icon: <UserOutlined/>
},
{
key: '2',
label: 'Резервное копирование и восстановление',
children: <BackupManageTab/>,
icon: <ContainerOutlined/>
}
]
const AdminPage = () => {
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 (
<div style={adminPageUI.containerStyle}>
{adminPageData.isLoading ? (
<LoadingIndicator/>
) : (
<>
<Typography.Title level={1}>
<ControlOutlined/> Панель администратора
</Typography.Title>
<Button
type="primary"
onClick={adminPageUI.openCreateModal}
style={{marginBottom: 16}}
>
Создать пользователя
</Button>
<Table
columns={columns}
dataSource={adminPageData.users}
rowKey="id"
pagination={{pageSize: 10}}
loading={adminPageData.isLoading}
/>
<CreateUserModalForm/>
<UpdateUserModalForm/>
</>
)}
<div style={adminPageData.containerStyle}>
<>
<Typography.Title level={1}>
<ControlOutlined/> Панель администратора
</Typography.Title>
<Tabs
defaultActiveKey="1"
items={items}
/>
</>
</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 {useGetRolesQuery} from "../../../Api/rolesApi.js";
import {Grid} from "antd";
const {useBreakpoint} = Grid;
const useAdminPage = () => {
const {
data: users = [], isLoading, isError,
} = useGetAllUsersQuery(undefined, {
pollingInterval: 10000,
});
const screens = useBreakpoint();
const {data: roles = [], isLoading: isLoadingRoles, isError: isErrorRoles} = useGetRolesQuery(undefined, {
pollingInterval: 60000,
});
const containerStyle = {padding: screens.xs ? 16 : 24};
// const [updateUser, { isLoading: isUpdating, isError: isUpdateError }] = useUpdateUserMutation();
// const [createUser, { isLoading: isCreating, isError: isCreateError }] = useCreateUserMutation();
return {
users,
roles,
isLoading: isLoading || isLoadingRoles,
isError: isError || isErrorRoles,
updateUser: () => {
}, isUpdating: false, isUpdateError: false, createUser: () => {
}, isCreating: false, isCreateError: false,
containerStyle
};
};

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

View File

@ -1,13 +1,13 @@
import {Calendar} from "antd";
import "dayjs/locale/ru";
import CalendarCell from "../CalendarCell/CalendarCell.jsx";
import useAppointmentCalendarUI from "./useAppointmentCalendarUI.js";
import useAppointmentCalendar from "./useAppointmentCalendar.js";
import AppointmentsListModal from "../AppointmentsListModal/AppointmentsListModal.jsx";
import dayjs from "dayjs";
import PropTypes from "prop-types";
const AppointmentsCalendarTab = ({currentMonth, onMonthChange, appointments, scheduledAppointments}) => {
const appointmentsCalendarUI = useAppointmentCalendarUI(appointments, scheduledAppointments);
const AppointmentsCalendar = ({currentMonth, onMonthChange, appointments, scheduledAppointments}) => {
const appointmentsCalendarUI = useAppointmentCalendar(appointments, scheduledAppointments);
const dateCellRender = (value) => {
const appointmentsForDate = appointmentsCalendarUI.getAppointmentsByListAndDate(
@ -39,12 +39,11 @@ const AppointmentsCalendarTab = ({currentMonth, onMonthChange, appointments, sch
<div style={appointmentsCalendarUI.calendarContainerStyle}>
<Calendar
fullscreen={appointmentsCalendarUI.fullScreenCalendar}
value={currentMonth} // Используем currentMonth вместо selectedDate
onSelect={appointmentsCalendarUI.onSelect}
value={currentMonth}
onPanelChange={(value, mode) => {
appointmentsCalendarUI.onPanelChange(value, mode);
if (mode === "month") {
onMonthChange(value); // Вызываем onMonthChange при смене месяца
onMonthChange(value);
}
}}
cellRender={dateCellRender}
@ -54,11 +53,11 @@ const AppointmentsCalendarTab = ({currentMonth, onMonthChange, appointments, sch
);
};
AppointmentsCalendarTab.propTypes = {
AppointmentsCalendar.propTypes = {
currentMonth: PropTypes.object.isRequired,
onMonthChange: PropTypes.func.isRequired,
appointments: 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 useAppointmentCalendarUI = (appointments, scheduledAppointments) => {
const useAppointmentCalendar = (appointments, scheduledAppointments) => {
const dispatch = useDispatch();
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 {AppointmentPropType} from "../../../../../Types/appointmentPropType.js";
import {ScheduledAppointmentPropType} from "../../../../../Types/scheduledAppointmentPropType.js";
import useCalendarCellUI from "./useCalendarCellUI.js";
import useCalendarCell from "./useCalendarCell.js";
const CalendarCell = ({allAppointments, onCellClick, onItemClick}) => {
const {
@ -18,7 +18,7 @@ const CalendarCell = ({allAppointments, onCellClick, onItemClick}) => {
getBadgeText,
getTagColor,
getBadgeStatus,
} = useCalendarCellUI();
} = useCalendarCell();
return (
<div

View File

@ -1,7 +1,7 @@
import { useEffect, useRef, useState } from "react";
import dayjs from "dayjs";
const useCalendarCellUI = () => {
const useCalendarCell = () => {
const containerRef = useRef(null);
const [isCompressed, setIsCompressed] = useState(false);
const COMPRESSION_THRESHOLD = 70;
@ -53,7 +53,7 @@ const useCalendarCellUI = () => {
const compressedCountStyle = {
position: "absolute",
top: 2,
bottom: 12,
right: 2,
fontSize: 10,
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 { useDispatch, useSelector } from "react-redux";
import { notification } from "antd";
import { Grid } from "antd";
import {useEffect, useMemo, useState} from "react";
import {useDispatch, useSelector} from "react-redux";
import {notification} from "antd";
import {Grid} from "antd";
import {
useGetAppointmentsQuery,
useGetUpcomingAppointmentsQuery,
} from "../../../Api/appointmentsApi.js";
import { useGetAllPatientsQuery } from "../../../Api/patientsApi.js";
import {useGetAllPatientsQuery} from "../../../Api/patientsApi.js";
import {
openModal,
openScheduledModal,
@ -26,12 +26,12 @@ import timezone from "dayjs/plugin/timezone";
dayjs.extend(utc);
dayjs.extend(timezone);
const { useBreakpoint } = Grid;
const {useBreakpoint} = Grid;
const useAppointments = () => {
const dispatch = useDispatch();
const { userData } = useSelector(state => state.auth);
const { collapsed, siderWidth, hovered, selectedAppointment } = useSelector(state => state.appointmentsUI);
const {userData} = useSelector(state => state.auth);
const {collapsed, siderWidth, hovered, selectedAppointment} = useSelector(state => state.appointmentsUI);
const screens = useBreakpoint();
const [currentMonth, setCurrentMonth] = useState(dayjs().startOf('month'));
@ -47,7 +47,7 @@ const useAppointments = () => {
data: appointments = [],
isLoading: isLoadingAppointments,
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,
skip: !userData.id,
});
@ -56,7 +56,7 @@ const useAppointments = () => {
data: scheduledAppointments = [],
isLoading: isLoadingScheduledAppointments,
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,
skip: !userData.id,
});
@ -90,10 +90,10 @@ const useAppointments = () => {
const [localSiderWidth, setLocalSiderWidth] = useState(siderWidth);
const splitterStyle = { flex: 1 };
const splitterContentPanelStyle = { padding: 16 };
const splitterSiderPanelStyle = { padding: "16px", borderLeft: "1px solid #ddd", overflowY: "auto" };
const siderTitleStyle = { marginBottom: 36 };
const splitterStyle = {flex: 1};
const splitterContentPanelStyle = {padding: 16};
const splitterSiderPanelStyle = {padding: "16px", borderLeft: "1px solid #ddd", overflowY: "auto"};
const siderTitleStyle = {marginBottom: 36};
const siderButtonContainerStyle = {
position: "fixed",
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 useLoginPageUI from "./useLoginPageUI.js";
const {Title} = Typography;
const { Title } = Typography;
const LoginPage = () => {
const {onFinish, isLoading} = useLoginPage();
const {
containerStyle,
formContainerStyle,
@ -13,8 +11,10 @@ const LoginPage = () => {
logoBlockStyle,
logoStyle,
appNameStyle,
labels
} = useLoginPageUI();
labels,
isLoading,
onFinish,
} = useLoginPage();
return (
<Row justify="center" align="middle" style={containerStyle}>
@ -35,19 +35,19 @@ const LoginPage = () => {
{labels.title}
</Title>
<Form name="login" initialValues={{remember: true}} onFinish={onFinish}>
<Form name="login" initialValues={{ remember: true }} onFinish={onFinish}>
<Form.Item
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
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>

View File

@ -1,12 +1,73 @@
import { useDispatch } from "react-redux";
import { notification } from "antd";
import { useEffect, useRef } from "react";
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 { useLoginMutation } from "../../../Api/authApi.js";
import { checkAuth } from "../../../Redux/Slices/authSlice.js";
const { useBreakpoint } = Grid;
const useLoginPage = () => {
const navigate = useNavigate();
const dispatch = useDispatch();
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) => {
try {
@ -32,6 +93,13 @@ const useLoginPage = () => {
};
return {
containerStyle,
formContainerStyle,
titleStyle,
logoStyle,
appNameStyle,
labels,
logoBlockStyle,
onFinish,
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" ? (
<List
grid={{gutter: 16, column: 3}}
grid={{ gutter: 16, xs: 1, sm: 1, md: 2, lg: 3, xl: 4 }}
dataSource={patientsData.filteredPatients}
renderItem={patient => (
<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 LoadingIndicator from "../../Widgets/LoadingIndicator/LoadingIndicator.jsx";
import useProfilePage from "./useProfilePage.js";
import useProfilePageUI from "./useProfilePageUI.js";
import UpdateUserModalForm from "../../Dummies/UpdateUserModalForm/UpdateUserModalForm.jsx";
const ProfilePage = () => {
const {
userData,
isLoading,
isError,
} = useProfilePage();
const {
containerStyle,
cardStyle,
buttonStyle,
isLoading,
isError,
userData,
handleEditUser,
} = useProfilePageUI(userData);
} = useProfilePage();
if (isError) {
return (

View File

@ -1,8 +1,14 @@
import {
useGetAuthenticatedUserDataQuery,
} from "../../../Api/usersApi.js";
import {Grid} from "antd";
import {useDispatch} from "react-redux";
import {setSelectedUser} from "../../../Redux/Slices/usersSlice.js";
import {useGetAuthenticatedUserDataQuery} from "../../../Api/usersApi.js";
const {useBreakpoint} = Grid;
const useProfilePage = () => {
const dispatch = useDispatch();
const screens = useBreakpoint();
const {
data: userData = {},
isLoading: isLoadingUserData,
@ -11,10 +17,24 @@ const useProfilePage = () => {
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 {
userData,
containerStyle,
cardStyle,
buttonStyle,
isMobile: screens.xs,
isLoading: isLoadingUserData,
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 useScheduledAppointmentsViewModal from "./useScheduledAppointmentsViewModal.js";
import useScheduledAppointmentsViewModalUI from "./useScheduledAppointmentsViewModalUI.js";
const ScheduledAppointmentsViewModal = () => {
const scheduledAppointmentsViewModalData = useScheduledAppointmentsViewModal();
const {
selectedScheduledAppointment,
modalWidth,
@ -19,7 +17,7 @@ const ScheduledAppointmentsViewModal = () => {
onCancel,
cancelScheduledAppointment,
handleConvertToAppointment,
} = useScheduledAppointmentsViewModalUI(scheduledAppointmentsViewModalData.cancelAppointment);
} = useScheduledAppointmentsViewModal();
if (!selectedScheduledAppointment) {
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";
const useScheduledAppointmentsViewModal = () => {
const dispatch = useDispatch();
const { selectedScheduledAppointment } = useSelector((state) => state.appointmentsUI);
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;

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 PropTypes from "prop-types";
import { ViewModPropType } from "../../../Types/viewModPropType.js";
import useSelectViewModeUI from "./useSelectViewModeUI.js";
import useSelectViewMode from "./useSelectViewMode.js";
const { Option } = Select;
const SelectViewMode = ({ viewMode, setViewMode, localStorageKey, toolTipText, viewModes }) => {
const { selectStyle, handleChange } = useSelectViewModeUI({ setViewMode, localStorageKey });
const { selectStyle, handleChange } = useSelectViewMode({ setViewMode, localStorageKey });
return (
<Tooltip title={toolTipText}>

View File

@ -1,6 +1,6 @@
import { cacheInfo } from "../../../Utils/cachedInfoUtils.js";
const useSelectViewModeUI = ({ setViewMode, localStorageKey }) => {
const useSelectViewMode = ({ setViewMode, localStorageKey }) => {
const selectStyle = {
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);
};
const getCacheTimestamp = (key) => {
const timestamp = localStorage.getItem(`${key}Timestamp`);
if (!timestamp) return null;
return parseInt(timestamp);
};
export {cacheInfo, getCachedInfo, getCacheTimestamp};
export {cacheInfo, getCachedInfo};