Files
clinicpro/assets/admin/hooks/useResourceBooking.ts
T
hamedandClaude Opus 5 581a553516 refactor(resource): drop the branch domain from resources
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>
2026-08-03 14:57:47 +03:30

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