feat: AppointmentsPage: Добавлено отображение напоминаний

This commit is contained in:
Андрей Дувакин 2025-07-03 13:32:00 +05:00
parent 59b77a665b
commit a5fccd0710
6 changed files with 165 additions and 59 deletions

View File

@ -2,7 +2,7 @@ from typing import Sequence, Optional
from sqlalchemy import select, desc, func
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import joinedload
from datetime import date
from datetime import date, timedelta
from app.domain.models import Appointment
@ -43,6 +43,35 @@ class AppointmentsRepository:
result = await self.db.execute(stmt)
return result.scalars().all()
async def get_reminders(self, current_date: date) -> Sequence[Appointment]:
stmt = (
select(Appointment)
.options(joinedload(Appointment.type))
.options(joinedload(Appointment.patient))
.options(joinedload(Appointment.doctor))
.filter(Appointment.days_until_the_next_appointment.is_not(None))
)
result = await self.db.execute(stmt)
appointments = result.scalars().all()
filtered_appointments = []
for appointment in appointments:
next_appointment_date = (appointment.appointment_datetime + timedelta(
days=appointment.days_until_the_next_appointment)).date()
days_until = appointment.days_until_the_next_appointment
window_days = (
7 if days_until <= 90 else
14 if days_until <= 180 else
30 if days_until <= 365 else
60
)
window_start = next_appointment_date - timedelta(days=window_days)
window_end = next_appointment_date + timedelta(days=window_days)
if window_start <= current_date <= window_end:
filtered_appointments.append(appointment)
return filtered_appointments
async def get_upcoming_by_doctor_id(self, doctor_id: int) -> Sequence[Appointment]:
stmt = (
select(Appointment)

View File

@ -58,6 +58,22 @@ async def get_upcoming_appointments_by_doctor_id(
return await appointments_service.get_upcoming_appointments_by_doctor_id(doctor_id)
@router.get(
"/reminders/",
response_model=list[AppointmentEntity],
summary="Get appointment reminders",
description="Returns a list of appointments with upcoming follow-up reminders based on days_until_the_next_appointment",
)
async def get_appointment_reminders(
db: AsyncSession = Depends(get_db),
user=Depends(get_current_user),
current_date: date = Query(default_factory=date.today,
description="Current date for reminder calculation (YYYY-MM-DD)"),
):
appointments_service = AppointmentsService(db)
return await appointments_service.get_appointment_reminders(current_date)
@router.get(
"/patient/{patient_id}/",
response_model=list[AppointmentEntity],

View File

@ -49,6 +49,10 @@ class AppointmentsService:
appointments = await self.appointments_repository.get_upcoming_by_doctor_id(doctor_id)
return [self.model_to_entity(appointment) for appointment in appointments]
async def get_appointment_reminders(self, current_date: date) -> list[AppointmentEntity]:
appointments = await self.appointments_repository.get_reminders(current_date)
return [self.model_to_entity(appointment) for appointment in appointments]
async def get_appointments_by_patient_id(self, patient_id: int, start_date: date | None = None,
end_date: date | None = None) -> Optional[list[AppointmentEntity]]:
patient = await self.patients_repository.get_by_id(patient_id)

View File

@ -31,13 +31,11 @@ export const appointmentsApi = createApi({
}),
invalidatesTags: ['Appointment'],
}),
updateAppointment: builder.mutation({
query: ({id, data}) => ({
url: `/appointments/${id}/`,
method: 'PUT',
body: data,
getAppointmentReminders: builder.query({
query: (current_date) => ({
url: "/appointments/reminders/",
params: { current_date },
}),
invalidatesTags: ['Appointment'],
}),
}),
});
@ -47,5 +45,5 @@ export const {
useGetUpcomingAppointmentsQuery,
useGetByPatientIdQuery,
useCreateAppointmentMutation,
useUpdateAppointmentMutation,
useGetAppointmentRemindersQuery
} = appointmentsApi;

View File

@ -1,20 +1,23 @@
import { Badge, Button, FloatButton, List, Result, Row, Space, Tag, Typography } from "antd";
import { Splitter } from "antd";
import {Badge, Button, FloatButton, List, Result, Row, Space, Tag, Typography} from "antd";
import {Splitter} from "antd";
import {
CalendarOutlined,
MenuFoldOutlined,
MenuUnfoldOutlined,
PlusOutlined,
ClockCircleOutlined,
BellOutlined,
} from "@ant-design/icons";
import AppointmentsCalendar from "./Components/AppointmentCalendar/AppointmentsCalendar.jsx";
import useAppointments from "./useAppointments.js";
import useAppointmentsPage from "./useAppointmentsPage.js";
import dayjs from 'dayjs';
import LoadingIndicator from "../../Widgets/LoadingIndicator/LoadingIndicator.jsx";
import AppointmentFormModal from "../../Dummies/AppointmentFormModal/AppointmentFormModal.jsx";
import AppointmentViewModal from "../../Dummies/AppointmentViewModal/AppointmentViewModal.jsx";
import ScheduledAppointmentFormModal from "../../Dummies/ScheduledAppintmentFormModal/ScheduledAppointmentFormModal.jsx";
import ScheduledAppointmentsViewModal from "../../Widgets/ScheduledAppointmentsViewModal/ScheduledAppointmentsViewModal.jsx";
import ScheduledAppointmentFormModal
from "../../Dummies/ScheduledAppintmentFormModal/ScheduledAppointmentFormModal.jsx";
import ScheduledAppointmentsViewModal
from "../../Widgets/ScheduledAppointmentsViewModal/ScheduledAppointmentsViewModal.jsx";
import AppointmentsListModal from "./Components/AppointmentsListModal/AppointmentsListModal.jsx";
const AppointmentsPage = () => {
@ -44,7 +47,7 @@ const AppointmentsPage = () => {
handleMonthChange,
handleEventClick,
openCreateAppointmentModal,
} = useAppointments();
} = useAppointmentsPage();
if (isError) return (
<Result
@ -56,20 +59,24 @@ const AppointmentsPage = () => {
return (
<>
<Typography.Title level={1}><CalendarOutlined /> Приемы</Typography.Title>
<Typography.Title level={1}><CalendarOutlined/> Приемы</Typography.Title>
{isLoading ? (
<LoadingIndicator />
<LoadingIndicator/>
) : (
<>
<Row justify="end" style={{ marginBottom: 10, marginRight: "2.4rem" }}>
<Row justify="end" style={{marginBottom: 10, marginRight: "2.4rem"}}>
<Space direction={"vertical"}>
<Tag color={"blue"} style={{ width: "100%" }}>
<Tag color={"blue"} style={{width: "100%"}}>
<Badge status={"processing"}
text={<span style={badgeTextStyle}>Запланированный прием</span>} />
text={<span style={badgeTextStyle}>Запланированный прием</span>}/>
</Tag>
<Tag color={"green"} style={{ width: "100%" }}>
<Tag color={"green"} style={{width: "100%"}}>
<Badge status={"success"}
text={<span style={badgeTextStyle}>Прошедший прием</span>} />
text={<span style={badgeTextStyle}>Прошедший прием</span>}/>
</Tag>
<Tag color={"yellow"} style={{width: "100%"}}>
<Badge status={"warning"}
text={<span style={badgeTextStyle}>Напоминание о приеме</span>}/>
</Tag>
</Space>
</Row>
@ -89,8 +96,8 @@ const AppointmentsPage = () => {
<AppointmentsCalendar
currentMonth={currentMonth}
onMonthChange={handleMonthChange}
appointments={appointments} // Добавляем
scheduledAppointments={scheduledAppointments} // Добавляем
appointments={appointments}
scheduledAppointments={scheduledAppointments}
/>
</Splitter.Panel>
{showSplitterPanel && (
@ -105,10 +112,7 @@ const AppointmentsPage = () => {
</Typography.Title>
{upcomingEvents.length ? (
<List
dataSource={upcomingEvents.sort((a, b) =>
dayjs(a.appointment_datetime || a.scheduled_datetime).diff(
dayjs(b.appointment_datetime || b.scheduled_datetime)
))}
dataSource={upcomingEvents}
renderItem={(item) => (
<List.Item
onClick={() => handleEventClick(item)}
@ -116,32 +120,46 @@ const AppointmentsPage = () => {
padding: "12px",
marginBottom: "8px",
borderRadius: "4px",
background: item.appointment_datetime ? "#f6ffed" : "#e6f7ff",
background: item.type === "reminder" ? "#fff7e6" :
item.appointment_datetime ? "#f6ffed" : "#e6f7ff",
cursor: "pointer",
transition: "background 0.3s",
}}
onMouseEnter={(e) => (e.currentTarget.style.background = item.appointment_datetime ? "#efffdb" : "#d9efff")}
onMouseLeave={(e) => (e.currentTarget.style.background = item.appointment_datetime ? "#f6ffed" : "#e6f7ff")}
onMouseEnter={(e) => (e.currentTarget.style.background =
item.type === "reminder" ? "#ffecd2" :
item.appointment_datetime ? "#efffdb" : "#d9efff")}
onMouseLeave={(e) => (e.currentTarget.style.background =
item.type === "reminder" ? "#fff7e6" :
item.appointment_datetime ? "#f6ffed" : "#e6f7ff")}
>
<Space direction="vertical" size={2}>
<Space>
{item.appointment_datetime ? (
<ClockCircleOutlined style={{ color: "#52c41a" }} />
{item.type === "reminder" ? (
<BellOutlined style={{color: "#fa8c16"}}/>
) : item.appointment_datetime ? (
<ClockCircleOutlined style={{color: "#52c41a"}}/>
) : (
<CalendarOutlined style={{ color: "#1890ff" }} />
<CalendarOutlined style={{color: "#1890ff"}}/>
)}
<Typography.Text strong>
{dayjs(item.appointment_datetime || item.scheduled_datetime).format('DD.MM.YYYY HH:mm')}
{dayjs(item.type === "reminder" ? item.reminder_datetime :
(item.appointment_datetime || item.scheduled_datetime))
.format('DD.MM.YYYY HH:mm')}
</Typography.Text>
</Space>
<Typography.Text>
{item.appointment_datetime ? 'Прием' : 'Запланировано'}
{item.type === "reminder" ? "Напоминание" :
item.appointment_datetime ? "Прием" : "Запланировано"}
{item.patient ? ` - ${item.patient.last_name} ${item.patient.first_name}` : ''}
</Typography.Text>
<Typography.Text type="secondary">
Тип: {item.type?.title || 'Не указан'}
</Typography.Text>
{dayjs(item.appointment_datetime || item.scheduled_datetime).isSame(dayjs(), 'day') && (
{item.type !== "reminder" && (
<Typography.Text type="secondary">
Тип: {item.type === "reminder" ? item.type.title : item.type?.title || 'Не указан'}
</Typography.Text>
)}
{dayjs(item.type === "reminder" ? item.reminder_datetime :
(item.appointment_datetime || item.scheduled_datetime))
.isSame(dayjs(), 'day') && (
<Typography.Text type="warning">Сегодня</Typography.Text>
)}
</Space>
@ -162,7 +180,7 @@ const AppointmentsPage = () => {
<Button
type="primary"
onClick={handleToggleSider}
icon={collapsed ? <MenuUnfoldOutlined /> : <MenuFoldOutlined />}
icon={collapsed ? <MenuUnfoldOutlined/> : <MenuFoldOutlined/>}
style={siderButtonStyle}
>
{siderButtonText}
@ -172,30 +190,30 @@ const AppointmentsPage = () => {
placement={"left"}
trigger="hover"
type="primary"
icon={<PlusOutlined />}
icon={<PlusOutlined/>}
tooltip="Создать"
>
<FloatButton
icon={<PlusOutlined />}
icon={<PlusOutlined/>}
onClick={openCreateAppointmentModal}
tooltip="Прием"
/>
<FloatButton
icon={<CalendarOutlined />}
icon={<CalendarOutlined/>}
onClick={openCreateScheduledAppointmentModal}
tooltip="Запланированный прием"
/>
</FloatButton.Group>
<AppointmentFormModal />
<AppointmentViewModal />
<ScheduledAppointmentFormModal />
<ScheduledAppointmentsViewModal />
<AppointmentsListModal />
<AppointmentFormModal/>
<AppointmentViewModal/>
<ScheduledAppointmentFormModal/>
<ScheduledAppointmentsViewModal/>
<AppointmentsListModal/>
</>
)}
</>
);
};
export default AppointmentsPage;
export default AppointmentsPage;

View File

@ -3,6 +3,7 @@ import {useDispatch, useSelector} from "react-redux";
import {notification} from "antd";
import {Grid} from "antd";
import {
useGetAppointmentRemindersQuery,
useGetAppointmentsQuery,
useGetUpcomingAppointmentsQuery,
} from "../../../Api/appointmentsApi.js";
@ -28,7 +29,7 @@ dayjs.extend(timezone);
const {useBreakpoint} = Grid;
const useAppointments = () => {
const useAppointmentsPage = () => {
const dispatch = useDispatch();
const {userData} = useSelector(state => state.auth);
const {collapsed, siderWidth, hovered, selectedAppointment} = useSelector(state => state.appointmentsUI);
@ -88,6 +89,15 @@ const useAppointments = () => {
skip: !userData.id,
});
const {
data: reminders = [],
isLoading: isLoadingReminders,
isError: isErrorReminders,
} = useGetAppointmentRemindersQuery(new Date().toISOString().split("T")[0], {
skip: !userData,
pollingInterval: 60000,
});
const [localSiderWidth, setLocalSiderWidth] = useState(siderWidth);
const splitterStyle = {flex: 1};
@ -133,7 +143,9 @@ const useAppointments = () => {
};
const handleEventClick = (event) => {
if (event.appointment_datetime) {
if (event.type === "reminder") {
dispatch(setSelectedAppointment(event));
} else if (event.appointment_datetime) {
dispatch(setSelectedAppointment(event));
} else {
dispatch(setSelectedScheduledAppointment(event));
@ -150,14 +162,34 @@ const useAppointments = () => {
hovered ? (collapsed ? "Показать предстоящие события" : "Скрыть предстоящие события") : "",
[collapsed, hovered]
);
const showSplitterPanel = useMemo(() => !collapsed && !screens.xs, [collapsed, screens]);
const upcomingEvents = useMemo(() =>
[...upcomingAppointments, ...upcomingScheduledAppointments]
.sort((a, b) => dayjs(a.appointment_datetime || a.scheduled_datetime) - dayjs(b.appointment_datetime || b.scheduled_datetime))
.slice(0, 5),
[upcomingAppointments, upcomingScheduledAppointments]
);
const upcomingEvents = useMemo(() => {
const remindersWithType = reminders.map(reminder => ({
...reminder,
type: "reminder",
reminder_datetime: dayjs(reminder.appointment_datetime).add(reminder.days_until_the_next_appointment, 'day').toISOString(),
}));
const appointmentsWithType = upcomingAppointments.map(appointment => ({
...appointment,
type: "appointment",
}));
const scheduledAppointmentsWithType = upcomingScheduledAppointments.map(scheduled => ({
...scheduled,
type: "scheduledAppointment",
}));
return [
...appointmentsWithType,
...scheduledAppointmentsWithType,
...remindersWithType
].sort((a, b) => {
const dateA = a.type === "reminder" ? a.reminder_datetime : (a.appointment_datetime || a.scheduled_datetime);
const dateB = b.type === "reminder" ? b.reminder_datetime : (b.appointment_datetime || b.scheduled_datetime);
return dayjs(dateA).diff(dayjs(dateB));
}).slice(0, 5);
}, [upcomingAppointments, upcomingScheduledAppointments, reminders]);
useEffect(() => {
document.title = "Приемы";
@ -199,18 +231,27 @@ const useAppointments = () => {
placement: 'topRight',
});
}
if (isErrorReminders) {
notification.error({
message: 'Ошибка',
description: 'Ошибка загрузки напоминаний.',
placement: 'topRight',
});
}
}, [
isErrorAppointments,
isErrorScheduledAppointments,
isErrorPatients,
isErrorUpcomingAppointments,
isErrorUpcomingScheduledAppointments
isErrorUpcomingScheduledAppointments,
isErrorReminders
]);
return {
patients,
appointments,
scheduledAppointments,
reminders,
isLoading: isLoadingAppointments || isLoadingScheduledAppointments || isLoadingPatients ||
isLoadingUpcomingAppointments || isLoadingUpcomingScheduledAppointments,
isError: isErrorAppointments || isErrorScheduledAppointments || isErrorPatients ||
@ -242,4 +283,4 @@ const useAppointments = () => {
};
};
export default useAppointments;
export default useAppointmentsPage;