Files
clinicpro/assets/admin/hooks/useCourses.ts
T
hamedandClaude Opus 5 fc504f4415 feat(course): treatment courses with protocol-driven session planning
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>
2026-07-31 11:33:07 +03:30

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 };
}