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 import select, desc, func
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import joinedload from sqlalchemy.orm import joinedload
from datetime import date from datetime import date, timedelta
from app.domain.models import Appointment from app.domain.models import Appointment
@ -43,6 +43,35 @@ class AppointmentsRepository:
result = await self.db.execute(stmt) result = await self.db.execute(stmt)
return result.scalars().all() 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]: async def get_upcoming_by_doctor_id(self, doctor_id: int) -> Sequence[Appointment]:
stmt = ( stmt = (
select(Appointment) 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) 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( @router.get(
"/patient/{patient_id}/", "/patient/{patient_id}/",
response_model=list[AppointmentEntity], response_model=list[AppointmentEntity],

View File

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

View File

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

View File

@ -6,15 +6,18 @@ import {
MenuUnfoldOutlined, MenuUnfoldOutlined,
PlusOutlined, PlusOutlined,
ClockCircleOutlined, ClockCircleOutlined,
BellOutlined,
} from "@ant-design/icons"; } from "@ant-design/icons";
import AppointmentsCalendar from "./Components/AppointmentCalendar/AppointmentsCalendar.jsx"; import AppointmentsCalendar from "./Components/AppointmentCalendar/AppointmentsCalendar.jsx";
import useAppointments from "./useAppointments.js"; import useAppointmentsPage from "./useAppointmentsPage.js";
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import LoadingIndicator from "../../Widgets/LoadingIndicator/LoadingIndicator.jsx"; import LoadingIndicator from "../../Widgets/LoadingIndicator/LoadingIndicator.jsx";
import AppointmentFormModal from "../../Dummies/AppointmentFormModal/AppointmentFormModal.jsx"; import AppointmentFormModal from "../../Dummies/AppointmentFormModal/AppointmentFormModal.jsx";
import AppointmentViewModal from "../../Dummies/AppointmentViewModal/AppointmentViewModal.jsx"; import AppointmentViewModal from "../../Dummies/AppointmentViewModal/AppointmentViewModal.jsx";
import ScheduledAppointmentFormModal from "../../Dummies/ScheduledAppintmentFormModal/ScheduledAppointmentFormModal.jsx"; import ScheduledAppointmentFormModal
import ScheduledAppointmentsViewModal from "../../Widgets/ScheduledAppointmentsViewModal/ScheduledAppointmentsViewModal.jsx"; from "../../Dummies/ScheduledAppintmentFormModal/ScheduledAppointmentFormModal.jsx";
import ScheduledAppointmentsViewModal
from "../../Widgets/ScheduledAppointmentsViewModal/ScheduledAppointmentsViewModal.jsx";
import AppointmentsListModal from "./Components/AppointmentsListModal/AppointmentsListModal.jsx"; import AppointmentsListModal from "./Components/AppointmentsListModal/AppointmentsListModal.jsx";
const AppointmentsPage = () => { const AppointmentsPage = () => {
@ -44,7 +47,7 @@ const AppointmentsPage = () => {
handleMonthChange, handleMonthChange,
handleEventClick, handleEventClick,
openCreateAppointmentModal, openCreateAppointmentModal,
} = useAppointments(); } = useAppointmentsPage();
if (isError) return ( if (isError) return (
<Result <Result
@ -71,6 +74,10 @@ const AppointmentsPage = () => {
<Badge status={"success"} <Badge status={"success"}
text={<span style={badgeTextStyle}>Прошедший прием</span>}/> text={<span style={badgeTextStyle}>Прошедший прием</span>}/>
</Tag> </Tag>
<Tag color={"yellow"} style={{width: "100%"}}>
<Badge status={"warning"}
text={<span style={badgeTextStyle}>Напоминание о приеме</span>}/>
</Tag>
</Space> </Space>
</Row> </Row>
<Splitter <Splitter
@ -89,8 +96,8 @@ const AppointmentsPage = () => {
<AppointmentsCalendar <AppointmentsCalendar
currentMonth={currentMonth} currentMonth={currentMonth}
onMonthChange={handleMonthChange} onMonthChange={handleMonthChange}
appointments={appointments} // Добавляем appointments={appointments}
scheduledAppointments={scheduledAppointments} // Добавляем scheduledAppointments={scheduledAppointments}
/> />
</Splitter.Panel> </Splitter.Panel>
{showSplitterPanel && ( {showSplitterPanel && (
@ -105,10 +112,7 @@ const AppointmentsPage = () => {
</Typography.Title> </Typography.Title>
{upcomingEvents.length ? ( {upcomingEvents.length ? (
<List <List
dataSource={upcomingEvents.sort((a, b) => dataSource={upcomingEvents}
dayjs(a.appointment_datetime || a.scheduled_datetime).diff(
dayjs(b.appointment_datetime || b.scheduled_datetime)
))}
renderItem={(item) => ( renderItem={(item) => (
<List.Item <List.Item
onClick={() => handleEventClick(item)} onClick={() => handleEventClick(item)}
@ -116,32 +120,46 @@ const AppointmentsPage = () => {
padding: "12px", padding: "12px",
marginBottom: "8px", marginBottom: "8px",
borderRadius: "4px", borderRadius: "4px",
background: item.appointment_datetime ? "#f6ffed" : "#e6f7ff", background: item.type === "reminder" ? "#fff7e6" :
item.appointment_datetime ? "#f6ffed" : "#e6f7ff",
cursor: "pointer", cursor: "pointer",
transition: "background 0.3s", transition: "background 0.3s",
}} }}
onMouseEnter={(e) => (e.currentTarget.style.background = item.appointment_datetime ? "#efffdb" : "#d9efff")} onMouseEnter={(e) => (e.currentTarget.style.background =
onMouseLeave={(e) => (e.currentTarget.style.background = item.appointment_datetime ? "#f6ffed" : "#e6f7ff")} 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 direction="vertical" size={2}>
<Space> <Space>
{item.appointment_datetime ? ( {item.type === "reminder" ? (
<BellOutlined style={{color: "#fa8c16"}}/>
) : item.appointment_datetime ? (
<ClockCircleOutlined style={{color: "#52c41a"}}/> <ClockCircleOutlined style={{color: "#52c41a"}}/>
) : ( ) : (
<CalendarOutlined style={{color: "#1890ff"}}/> <CalendarOutlined style={{color: "#1890ff"}}/>
)} )}
<Typography.Text strong> <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> </Typography.Text>
</Space> </Space>
<Typography.Text> <Typography.Text>
{item.appointment_datetime ? 'Прием' : 'Запланировано'} {item.type === "reminder" ? "Напоминание" :
item.appointment_datetime ? "Прием" : "Запланировано"}
{item.patient ? ` - ${item.patient.last_name} ${item.patient.first_name}` : ''} {item.patient ? ` - ${item.patient.last_name} ${item.patient.first_name}` : ''}
</Typography.Text> </Typography.Text>
{item.type !== "reminder" && (
<Typography.Text type="secondary"> <Typography.Text type="secondary">
Тип: {item.type?.title || 'Не указан'} Тип: {item.type === "reminder" ? item.type.title : item.type?.title || 'Не указан'}
</Typography.Text> </Typography.Text>
{dayjs(item.appointment_datetime || item.scheduled_datetime).isSame(dayjs(), 'day') && ( )}
{dayjs(item.type === "reminder" ? item.reminder_datetime :
(item.appointment_datetime || item.scheduled_datetime))
.isSame(dayjs(), 'day') && (
<Typography.Text type="warning">Сегодня</Typography.Text> <Typography.Text type="warning">Сегодня</Typography.Text>
)} )}
</Space> </Space>

View File

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