Resources never needed a branch: devices and rooms belong to the clinic itself, and the picker always had exactly one option — a mandatory click that decided nothing. - `address_uuid` is now optional on resource and pool creation; when it is missing the environment's own address is used. Clients still sending it keep working. - The panel no longer asks for or displays a branch anywhere: resource form, list column and filter, pool form and column, detail row, and the resource-first booking page. - Availability no longer gates on `doctor_addresses.active`. That gate shut down every device of a clinic whose address row happened to be inactive, with a message no page in the panel could act on — no endpoint writes that column at all. `address_id` stays on the resource: the timezone and the tenant pair are derived from it. It is simply no longer the user's decision. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
153 lines
5.5 KiB
TypeScript
153 lines
5.5 KiB
TypeScript
import { useMutation, useQuery } from '@tanstack/react-query';
|
|
import { toast } from 'sonner';
|
|
import { api, ApiError, type ApiResponse } from '../lib/api';
|
|
|
|
/**
|
|
* جستجوی وقت، رزرو موقت و ثبت نهایی در حالت منبعمحور.
|
|
*
|
|
* سه عمل جدا هستند و باید جدا بمانند: بین «دیدن وقت» و «گرفتنش» صندلی هنوز آزاد است،
|
|
* و بین «گرفتن» و «ثبت» یک مهلت وجود دارد که اگر نگذرد، ظرفیت برای همیشه قفل میماند.
|
|
*/
|
|
export interface ResourceRef {
|
|
uuid: string;
|
|
name: string;
|
|
}
|
|
|
|
export type SlotAssignment = Record<string, ResourceRef[]>;
|
|
|
|
export interface AvailableSlot {
|
|
start: number;
|
|
end: number;
|
|
assignment: SlotAssignment;
|
|
}
|
|
|
|
export interface PlanSegment {
|
|
sequence: number;
|
|
name: string;
|
|
offset_minutes: number;
|
|
duration_minutes: number;
|
|
patient_present: boolean;
|
|
requirements: { role: string; role_name: string; count: number }[];
|
|
}
|
|
|
|
export interface AvailabilityResult {
|
|
plan: { total_minutes: number; segments: PlanSegment[] };
|
|
slots: AvailableSlot[];
|
|
/** خالی بودن فهرست خطا نیست؛ این میگوید چرا خالی است. */
|
|
reason: string | null;
|
|
}
|
|
|
|
export interface HoldResult {
|
|
hold_uuid: string;
|
|
starts_at: number;
|
|
ends_at: number;
|
|
expires_at: number;
|
|
confirmed: boolean;
|
|
assignment: SlotAssignment;
|
|
}
|
|
|
|
export const REASON_LABELS: Record<string, string> = {
|
|
no_capacity_in_range: 'در این بازه هیچ ظرفیتی نیست — بازه را بزرگتر کنید یا سرویس دیگری را امتحان کنید.',
|
|
no_working_hours: 'در این بازه ساعت کاری تعریف نشده است.',
|
|
no_eligible_resource: 'هیچ منبعی شرایط بخشهای این خدمت را ندارد.',
|
|
};
|
|
|
|
function fail(e: unknown, fallback: string) {
|
|
toast.error(e instanceof ApiError ? e.message : fallback);
|
|
}
|
|
|
|
export function useAvailabilitySearch(
|
|
params: { serviceUuid: string; branchUuid: string; from: number; to: number; stepMinutes?: number },
|
|
enabled: boolean,
|
|
) {
|
|
const query = useQuery({
|
|
queryKey: ['resource-availability', params],
|
|
queryFn: () =>
|
|
api.post<ApiResponse<AvailabilityResult>>('/api/v1/appointment-availability', {
|
|
service_uuid: params.serviceUuid,
|
|
branch_uuid: params.branchUuid,
|
|
from: params.from,
|
|
to: params.to,
|
|
...(params.stepMinutes ? { step_minutes: params.stepMinutes } : {}),
|
|
}),
|
|
enabled: enabled && !!params.serviceUuid && !!params.branchUuid,
|
|
retry: false,
|
|
});
|
|
|
|
return {
|
|
result: query.data?.data,
|
|
loading: query.isFetching,
|
|
error: query.error,
|
|
refetch: query.refetch,
|
|
};
|
|
}
|
|
|
|
export function useHold() {
|
|
const create = useMutation({
|
|
mutationFn: (body: {
|
|
service_uuid: string;
|
|
branch_uuid: string;
|
|
start: number;
|
|
assignment: Record<string, string[]>;
|
|
item_uuids?: string[];
|
|
patient_gender?: string;
|
|
}) => api.post<ApiResponse<HoldResult>>('/api/v1/appointment-hold', body),
|
|
// ۴۰۹ یعنی همین لحظه کس دیگری گرفت — پیام سرور دقیقاً همین را میگوید.
|
|
onError: (e) => fail(e, 'گرفتن این زمان ناموفق بود'),
|
|
});
|
|
|
|
const release = useMutation({
|
|
mutationFn: (uuid: string) => api.delete<ApiResponse<null>>(`/api/v1/appointment-hold/${uuid}`),
|
|
onSuccess: () => toast.success('رزرو موقت آزاد شد'),
|
|
onError: (e) => fail(e, 'آزادسازی ناموفق بود'),
|
|
});
|
|
|
|
const confirm = useMutation({
|
|
mutationFn: (body: { hold_uuid: string; doctor_uuid: string; patient_uuid?: string }) =>
|
|
api.post<ApiResponse<{ uuid: string }>>('/api/v1/appointment-confirm', body),
|
|
onSuccess: () => toast.success('نوبت ثبت شد'),
|
|
onError: (e) => fail(e, 'ثبت نهایی ناموفق بود'),
|
|
});
|
|
|
|
/**
|
|
* جابهجایی: رزرو موقتِ زمان تازه از قبل گرفته شده و اینجا فقط تأیید میشود.
|
|
*
|
|
* عمداً از `confirm` جداست — سرور در همین یک درخواست زمان قدیم را هم آزاد میکند، پس
|
|
* لغو دستی پیش از رزرو یعنی پنجرهای که در آن بیمار اصلاً نوبت ندارد.
|
|
*/
|
|
const rebook = useMutation({
|
|
mutationFn: ({ appointmentUuid, holdUuid }: { appointmentUuid: string; holdUuid: string }) =>
|
|
api.post<ApiResponse<{ appointment_uuid: string; starts_at: number }>>(
|
|
`/api/v1/appointment/${appointmentUuid}/rebook`,
|
|
{ hold_uuid: holdUuid },
|
|
),
|
|
onSuccess: () => toast.success('نوبت جابهجا شد'),
|
|
onError: (e) => fail(e, 'جابهجایی نوبت ناموفق بود'),
|
|
});
|
|
|
|
return { create, release, confirm, rebook };
|
|
}
|
|
|
|
export interface AppointmentSegmentRow {
|
|
sequence: number;
|
|
name: string;
|
|
starts_at: number;
|
|
ends_at: number;
|
|
duration_minutes: number;
|
|
patient_present: boolean;
|
|
}
|
|
|
|
/** فهرست خالی یعنی نوبت اسلاتی است، نه اینکه چیزی خراب باشد. */
|
|
export function useAppointmentSegments(uuid: string | undefined) {
|
|
const query = useQuery({
|
|
queryKey: ['appointment-segments', uuid],
|
|
queryFn: () =>
|
|
api.get<ApiResponse<AppointmentSegmentRow[]>>(`/api/v1/appointment/${uuid}/segments`),
|
|
enabled: !!uuid,
|
|
});
|
|
|
|
const rows = query.data?.data;
|
|
|
|
return { segments: Array.isArray(rows) ? rows : [], loading: query.isLoading };
|
|
}
|