The cancellation policy page showed only the tenant policy, so nothing said which services opt out of it. Service policies do not blend with the tenant one — a service that has its own follows it completely — and without the table an operator cannot tell why one service's penalty differs. It lists them with a link to each service. The waitlist had the matches endpoint and no way to reach it. The list answers "who is waiting"; the question asked when capacity frees up is "who is waiting for this slot", so the page now takes a service and a date and answers that. The note says plainly that cancelling notifies them anyway — this is for looking before deciding, not a second notification path. Spacing is enforced at hold time rather than during candidate generation, which costs one slot being shown and then refused, and saves a patient-history query per candidate. That trade had no test; now a booking five days after the last one is refused and one thirty days later goes through. Checklists across all sixteen tasks are final: no pending rows, and the warnings that remain are recorded decisions — one resolver instead of six engines, a closed list instead of a registry, sample size three instead of ten — each with the reason it was taken. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
139 lines
5.0 KiB
TypeScript
139 lines
5.0 KiB
TypeScript
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
|
import { toast } from 'sonner';
|
|
import { api, ApiError, type ApiResponse } from '../lib/api';
|
|
import type { CancellationPolicy, CancellationPreview, CancellationResult, WaitlistEntry } from '../types';
|
|
|
|
/**
|
|
* سیاست لغو و لیست انتظار.
|
|
*
|
|
* پیشنمایش لغو همیشه از سرور میآید — همان محاسبهای که خودِ لغو انجام میدهد، تا عددی
|
|
* که کاربر میبیند با آنچه کسر میشود یکی باشد.
|
|
*/
|
|
const POLICY_KEY = ['cancellation-policy'];
|
|
const WAITLIST_KEY = ['waitlist'];
|
|
|
|
function fail(e: unknown, fallback: string) {
|
|
toast.error(e instanceof ApiError ? e.message : fallback);
|
|
}
|
|
|
|
interface PolicyResponse {
|
|
default: CancellationPolicy | null;
|
|
overrides: CancellationPolicy[];
|
|
}
|
|
|
|
export function useCancellationPolicy() {
|
|
const qc = useQueryClient();
|
|
|
|
const query = useQuery({
|
|
queryKey: POLICY_KEY,
|
|
queryFn: () => api.get<ApiResponse<PolicyResponse>>('/api/v1/cancellation-policy'),
|
|
});
|
|
|
|
const save = useMutation({
|
|
mutationFn: (body: Record<string, unknown>) =>
|
|
api.put<ApiResponse<CancellationPolicy>>('/api/v1/cancellation-policy', body),
|
|
onSuccess: () => {
|
|
toast.success('سیاست لغو ذخیره شد');
|
|
qc.invalidateQueries({ queryKey: POLICY_KEY });
|
|
},
|
|
onError: (e) => fail(e, 'ذخیرهٔ سیاست ناموفق بود'),
|
|
});
|
|
|
|
return {
|
|
policy: query.data?.data?.default ?? null,
|
|
overrides: query.data?.data?.overrides ?? [],
|
|
loading: query.isLoading,
|
|
save,
|
|
};
|
|
}
|
|
|
|
export function useCancellationPreview(appointmentUuid: string | undefined, by: 'user' | 'doctor') {
|
|
const query = useQuery({
|
|
queryKey: ['cancellation-preview', appointmentUuid, by],
|
|
queryFn: () =>
|
|
api.get<ApiResponse<CancellationPreview>>(
|
|
`/api/v1/appointment/${appointmentUuid}/cancellation-preview?by=${by}`,
|
|
),
|
|
enabled: !!appointmentUuid,
|
|
});
|
|
|
|
return { preview: query.data?.data, loading: query.isLoading };
|
|
}
|
|
|
|
export function useCancelAppointment() {
|
|
const qc = useQueryClient();
|
|
|
|
return useMutation({
|
|
mutationFn: ({ uuid, by, reason }: { uuid: string; by: 'user' | 'doctor'; reason?: string }) =>
|
|
api.post<ApiResponse<CancellationResult>>(`/api/v1/appointment/${uuid}/cancel`, {
|
|
by,
|
|
...(reason?.trim() ? { reason: reason.trim() } : {}),
|
|
}),
|
|
onSuccess: (res) => {
|
|
const notified = res.data.waitlist_notified;
|
|
toast.success(
|
|
notified > 0 ? `نوبت لغو شد و ${notified} نفر از لیست انتظار خبر شدند` : 'نوبت لغو شد',
|
|
);
|
|
qc.invalidateQueries({ queryKey: ['appointments'] });
|
|
qc.invalidateQueries({ queryKey: WAITLIST_KEY });
|
|
},
|
|
onError: (e) => fail(e, 'لغو نوبت ناموفق بود'),
|
|
});
|
|
}
|
|
|
|
/**
|
|
* درخواستهایی که با یک زمان مشخص میخوانند — ابزار لحظهٔ آزاد شدن ظرفیت.
|
|
*
|
|
* جدا از فهرست است چون سؤال متفاوتی میپرسد: نه «چه کسی منتظر است» بلکه «چه کسی
|
|
* برای **این** وقت منتظر است».
|
|
*/
|
|
export function useWaitlistMatches(params: { serviceUuid: string; start: number; branchUuid?: string }) {
|
|
const query = useQuery({
|
|
queryKey: ['waitlist-matches', params],
|
|
queryFn: () =>
|
|
api.get<ApiResponse<WaitlistEntry[]>>(
|
|
`/api/v1/waitlist/matches?service_uuid=${params.serviceUuid}&start=${params.start}` +
|
|
(params.branchUuid ? `&branch_uuid=${params.branchUuid}` : ''),
|
|
),
|
|
enabled: !!params.serviceUuid && params.start > 0,
|
|
});
|
|
|
|
return { matches: Array.isArray(query.data?.data) ? query.data.data : [], loading: query.isFetching };
|
|
}
|
|
|
|
export function useWaitlist(status?: string) {
|
|
const qc = useQueryClient();
|
|
const key = [...WAITLIST_KEY, status ?? ''];
|
|
|
|
const query = useQuery({
|
|
queryKey: key,
|
|
queryFn: () =>
|
|
api.get<ApiResponse<WaitlistEntry[]>>(`/api/v1/waitlist${status ? `?status=${status}` : ''}`),
|
|
});
|
|
|
|
const remove = useMutation({
|
|
mutationFn: (uuid: string) => api.delete<ApiResponse<null>>(`/api/v1/waitlist/${uuid}`),
|
|
onSuccess: () => {
|
|
toast.success('از لیست انتظار حذف شد');
|
|
qc.invalidateQueries({ queryKey: WAITLIST_KEY });
|
|
},
|
|
onError: (e) => fail(e, 'حذف ناموفق بود'),
|
|
});
|
|
|
|
return { entries: query.data?.data ?? [], loading: query.isLoading, remove };
|
|
}
|
|
|
|
/** خلاصهٔ عدم حضور یک بیمار — نشان است نه مانع؛ مسدودسازی کارِ قانون `eligibility` است. */
|
|
export function usePatientNoShows(patientUuid: string | undefined) {
|
|
const query = useQuery({
|
|
queryKey: ['patient-no-shows', patientUuid],
|
|
queryFn: () =>
|
|
api.get<ApiResponse<{ count: number; threshold: number; window_days: number; at_risk: boolean }>>(
|
|
`/api/v1/patient/${patientUuid}/no-shows`,
|
|
),
|
|
enabled: !!patientUuid,
|
|
});
|
|
|
|
return { noShows: query.data?.data ?? null };
|
|
}
|