Laser is six to eight sessions; the previous design only knew single appointments, which is the exception rather than the rule. - CourseProtocol per service: session count and three distinct spacings — min is the earliest that is clinically allowed, ideal is best, max is where the course starts losing its effect - Starting a course creates every session up front as `planned` and copies the protocol's numbers and per-session params, so changing the protocol tomorrow leaves a running course alone - Suggestions anchor on the last *completed* session, not the course start: when session 2 slips, session 3 moves with it - Slots are ranked by distance from ideal, not by earliest available — day 21 is worse than day 27 when 28 is the target - book-all is all-or-nothing inside one transaction, with a moving anchor and a 90-day horizon; sessions past the horizon stay planned and are reported, not treated as failures - The effective minimum is the stricter of the protocol and the task-09 spacing policy, so a clinic rule never fights the protocol - Cancelling one session returns only that session to planned; abandoning a course does not cancel its appointments, which stays an explicit decision One active course per (patient, service) via active_course_key, the same partial-uniqueness trick as Appointment::activeSlotKey. Admin: CourseProtocolsPage, TreatmentCoursePage and a courses tab on the patient record. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
139 lines
4.8 KiB
TypeScript
139 lines
4.8 KiB
TypeScript
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
|
import { toast } from 'sonner';
|
|
import { api, ApiError, type ApiResponse } from '../lib/api';
|
|
import type { CourseProtocol, NextSlotSuggestion, TreatmentCourse } from '../types';
|
|
|
|
/**
|
|
* پروتکل دوره و دورهٔ بیمار.
|
|
*
|
|
* `progress` هرگز در فرانت حساب نمیشود — سرور میدهدش، چون همانجاست که وضعیت جلسات
|
|
* منبع حقیقت است.
|
|
*/
|
|
const PROTOCOLS_KEY = ['course-protocols'];
|
|
|
|
function fail(e: unknown, fallback: string) {
|
|
toast.error(e instanceof ApiError ? e.message : fallback);
|
|
}
|
|
|
|
export function useCourseProtocols() {
|
|
const qc = useQueryClient();
|
|
|
|
const query = useQuery({
|
|
queryKey: PROTOCOLS_KEY,
|
|
queryFn: () => api.get<ApiResponse<CourseProtocol[]>>('/api/v1/course-protocols'),
|
|
});
|
|
|
|
const invalidate = () => qc.invalidateQueries({ queryKey: PROTOCOLS_KEY });
|
|
|
|
const create = useMutation({
|
|
mutationFn: (body: Record<string, unknown>) =>
|
|
api.post<ApiResponse<CourseProtocol>>('/api/v1/course-protocols', body),
|
|
onSuccess: () => {
|
|
toast.success('پروتکل ساخته شد');
|
|
invalidate();
|
|
},
|
|
onError: (e) => fail(e, 'ساخت پروتکل ناموفق بود'),
|
|
});
|
|
|
|
const update = useMutation({
|
|
mutationFn: ({ uuid, body }: { uuid: string; body: Record<string, unknown> }) =>
|
|
api.patch<ApiResponse<CourseProtocol>>(`/api/v1/course-protocol/${uuid}`, body),
|
|
onSuccess: () => {
|
|
toast.success('پروتکل بهروزرسانی شد');
|
|
invalidate();
|
|
},
|
|
onError: (e) => fail(e, 'بهروزرسانی پروتکل ناموفق بود'),
|
|
});
|
|
|
|
const deactivate = useMutation({
|
|
mutationFn: (uuid: string) => api.delete<ApiResponse<CourseProtocol>>(`/api/v1/course-protocol/${uuid}`),
|
|
onSuccess: () => {
|
|
toast.success('پروتکل غیرفعال شد');
|
|
invalidate();
|
|
},
|
|
onError: (e) => fail(e, 'غیرفعالسازی ناموفق بود'),
|
|
});
|
|
|
|
return { protocols: query.data?.data ?? [], loading: query.isLoading, create, update, deactivate };
|
|
}
|
|
|
|
export function usePatientCourses(patientUuid: string | undefined) {
|
|
const qc = useQueryClient();
|
|
const key = ['patient-courses', patientUuid];
|
|
|
|
const query = useQuery({
|
|
queryKey: key,
|
|
queryFn: () => api.get<ApiResponse<TreatmentCourse[]>>(`/api/v1/patient/${patientUuid}/courses`),
|
|
enabled: !!patientUuid,
|
|
});
|
|
|
|
const start = useMutation({
|
|
mutationFn: (body: { protocol_uuid: string; patient_package_uuid?: string }) =>
|
|
api.post<ApiResponse<TreatmentCourse>>('/api/v1/treatment-course', {
|
|
patient_uuid: patientUuid,
|
|
...body,
|
|
}),
|
|
onSuccess: () => {
|
|
toast.success('دوره شروع شد');
|
|
qc.invalidateQueries({ queryKey: key });
|
|
},
|
|
onError: (e) => fail(e, 'شروع دوره ناموفق بود'),
|
|
});
|
|
|
|
return { courses: query.data?.data ?? [], loading: query.isLoading, start };
|
|
}
|
|
|
|
export function useTreatmentCourse(courseUuid: string | undefined) {
|
|
const qc = useQueryClient();
|
|
const key = ['treatment-course', courseUuid];
|
|
|
|
const query = useQuery({
|
|
queryKey: key,
|
|
queryFn: () => api.get<ApiResponse<TreatmentCourse>>(`/api/v1/treatment-course/${courseUuid}`),
|
|
enabled: !!courseUuid,
|
|
});
|
|
|
|
const invalidate = () => {
|
|
qc.invalidateQueries({ queryKey: key });
|
|
qc.invalidateQueries({ queryKey: ['patient-courses'] });
|
|
};
|
|
|
|
const abandon = useMutation({
|
|
mutationFn: (reason: string) =>
|
|
api.post<ApiResponse<TreatmentCourse>>(`/api/v1/treatment-course/${courseUuid}/abandon`, { reason }),
|
|
onSuccess: () => {
|
|
toast.success('دوره رها شد');
|
|
invalidate();
|
|
},
|
|
onError: (e) => fail(e, 'رهاکردن دوره ناموفق بود'),
|
|
});
|
|
|
|
const bookAll = useMutation({
|
|
mutationFn: (body: { branch_uuid: string; doctor_uuid: string }) =>
|
|
api.post<ApiResponse<{ booked: number; remaining: number; message: string | null }>>(
|
|
`/api/v1/treatment-course/${courseUuid}/book-all`,
|
|
body,
|
|
),
|
|
onSuccess: (res) => {
|
|
toast.success(res.data.message ?? `${res.data.booked} جلسه رزرو شد`);
|
|
invalidate();
|
|
},
|
|
onError: (e) => fail(e, 'رزرو جلسات ناموفق بود'),
|
|
});
|
|
|
|
return { course: query.data?.data, loading: query.isLoading, abandon, bookAll };
|
|
}
|
|
|
|
export function useNextSlotSuggestion(courseUuid: string | undefined, branchUuid: string | undefined) {
|
|
const query = useQuery({
|
|
queryKey: ['course-next-slot', courseUuid, branchUuid],
|
|
queryFn: () =>
|
|
api.get<ApiResponse<NextSlotSuggestion>>(
|
|
`/api/v1/treatment-course/${courseUuid}/next-slot-suggestion?branch_uuid=${branchUuid}`,
|
|
),
|
|
enabled: !!courseUuid && !!branchUuid,
|
|
});
|
|
|
|
return { suggestion: query.data?.data, loading: query.isLoading };
|
|
}
|