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>
259 lines
12 KiB
TypeScript
259 lines
12 KiB
TypeScript
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
|
import { toast } from 'sonner';
|
|
import { api, ApiError, type ApiResponse } from '../lib/api';
|
|
import type {
|
|
ClinicResource, ResourcePool, ResourcePayload, ResourceServiceOffering, ResourceType, Skill,
|
|
} from '../types';
|
|
|
|
/**
|
|
* منابع: هر چیزی که ممکن است اشغال باشد. هر منبع مال محیط جاری است؛ لنگرش همان
|
|
* آدرس محل نوبتدهی است — پس فیلترها با `address_uuid` کار میکنند نه `branch_id`.
|
|
*/
|
|
const RESOURCES_KEY = 'resources';
|
|
/** کلیدِ کوئریِ صفحهٔ جزئیات — `useResourceDetail` هم از همین میسازد. */
|
|
const RESOURCE_DETAIL_KEY = 'resource-detail';
|
|
const TYPES_KEY = ['resource-types'];
|
|
const SKILLS_KEY = ['skills'];
|
|
const POOLS_KEY = ['resource-pools'];
|
|
|
|
function fail(e: unknown, fallback: string) {
|
|
toast.error(e instanceof ApiError ? e.message : fallback);
|
|
}
|
|
|
|
export type ResourceFilters = {
|
|
address_uuid?: string;
|
|
type_uuid?: string;
|
|
skill_uuid?: string;
|
|
active?: string;
|
|
};
|
|
|
|
function toQuery(filters: ResourceFilters): string {
|
|
const params = new URLSearchParams();
|
|
Object.entries(filters).forEach(([k, v]) => {
|
|
if (v) params.set(k, v);
|
|
});
|
|
const qs = params.toString();
|
|
return qs === '' ? '' : `?${qs}`;
|
|
}
|
|
|
|
export function useResources(filters: ResourceFilters = {}) {
|
|
const qc = useQueryClient();
|
|
|
|
/**
|
|
* هر جهش روی یک منبع، هم فهرست را کهنه میکند هم صفحهٔ جزئیاتِ همان منبع را —
|
|
* دو کوئری با دو کلید جدا (`resources` و `resource-detail`). فقط باطلکردن فهرست
|
|
* یعنی صفحهٔ جزئیات تا رفرشِ دستی مقدار قدیمی را نشان میدهد.
|
|
*/
|
|
const invalidate = () => {
|
|
qc.invalidateQueries({ queryKey: [RESOURCES_KEY] });
|
|
qc.invalidateQueries({ queryKey: [RESOURCE_DETAIL_KEY] });
|
|
};
|
|
|
|
const query = useQuery({
|
|
queryKey: [RESOURCES_KEY, filters],
|
|
queryFn: () => api.get<ApiResponse<ClinicResource[]>>(`/api/v1/resources${toQuery(filters)}`),
|
|
});
|
|
|
|
const create = useMutation({
|
|
mutationFn: (d: ResourcePayload) => api.post<ApiResponse<ClinicResource>>('/api/v1/resource', d),
|
|
onSuccess: () => { toast.success('منبع افزوده شد'); invalidate(); },
|
|
onError: (e) => fail(e, 'افزودن منبع ناموفق بود'),
|
|
});
|
|
|
|
// PATCH است و سرور هم واقعاً جزئی رفتار میکند (`array_key_exists` روی هر فیلد)،
|
|
// پس `Partial`: تغییر فقط `active` نباید کلاینت را مجبور کند `name` را هم بفرستد
|
|
// — فرستادنِ نامِ کهنه، تغییرِ همزمانِ نام را بازمیگرداند.
|
|
const update = useMutation({
|
|
mutationFn: ({ uuid, d }: { uuid: string; d: Partial<ResourcePayload> }) =>
|
|
api.patch<ApiResponse<ClinicResource>>(`/api/v1/resource/${uuid}`, d),
|
|
onSuccess: () => { toast.success('منبع بهروزرسانی شد'); invalidate(); },
|
|
onError: (e) => fail(e, 'بهروزرسانی منبع ناموفق بود'),
|
|
});
|
|
|
|
const remove = useMutation({
|
|
mutationFn: (uuid: string) => api.delete<ApiResponse<null>>(`/api/v1/resource/${uuid}`),
|
|
onSuccess: () => { toast.success('منبع حذف شد'); invalidate(); },
|
|
onError: (e) => fail(e, 'حذف منبع ناموفق بود'),
|
|
});
|
|
|
|
/** جایگزینی کامل: مهارتی که در بدنه نیست، برداشته میشود. */
|
|
const setSkills = useMutation({
|
|
mutationFn: ({ uuid, skills }: { uuid: string; skills: { skill_uuid: string; level: number }[] }) =>
|
|
api.put<ApiResponse<ClinicResource>>(`/api/v1/resource/${uuid}/skills`, { skills }),
|
|
onSuccess: () => { toast.success('مهارتها ذخیره شد'); invalidate(); },
|
|
onError: (e) => fail(e, 'ذخیرهٔ مهارتها ناموفق بود'),
|
|
});
|
|
|
|
/** دستهٔ منبع از کاتالوگ سراسری میآید؛ ساختنش فقط در «تنظیمات ← دستهبندیها» ممکن است. */
|
|
const setCategories = useMutation({
|
|
mutationFn: ({ uuid, categoryUuids }: { uuid: string; categoryUuids: string[] }) =>
|
|
api.put<ApiResponse<ClinicResource>>(`/api/v1/resource/${uuid}/categories`, { category_uuids: categoryUuids }),
|
|
onSuccess: () => { toast.success('دستهبندیهای منبع ذخیره شد'); invalidate(); },
|
|
onError: (e) => fail(e, 'ذخیرهٔ دستهبندیها ناموفق بود'),
|
|
});
|
|
|
|
return {
|
|
resources: query.data?.data ?? [],
|
|
loading: query.isLoading,
|
|
create, update, remove, setSkills, setCategories,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* سرویسهایی که یک منبع ارائه میدهد.
|
|
*
|
|
* مقدار مؤثر و منبعِ هر عدد از سرور میآید، نه از محاسبهٔ فرانت: زنجیرهٔ حل چهار سطح
|
|
* دارد و بازسازیاش اینجا یعنی دو پیادهسازی که با هم واگرا میشوند.
|
|
*/
|
|
export function useResourceServices(resourceUuid?: string) {
|
|
const qc = useQueryClient();
|
|
const key = ['resource-services', resourceUuid];
|
|
|
|
const query = useQuery({
|
|
queryKey: key,
|
|
queryFn: () => api.get<ApiResponse<ResourceServiceOffering[]>>(`/api/v1/resource/${resourceUuid}/services`),
|
|
enabled: !!resourceUuid,
|
|
});
|
|
|
|
const save = useMutation({
|
|
mutationFn: ({ uuid, services }: { uuid: string; services: Array<Record<string, unknown>> }) =>
|
|
api.put<ApiResponse<ResourceServiceOffering[]>>(`/api/v1/resource/${uuid}/services`, { services }),
|
|
onSuccess: () => {
|
|
toast.success('سرویسهای منبع ذخیره شد');
|
|
qc.invalidateQueries({ queryKey: ['resource-services'] });
|
|
qc.invalidateQueries({ queryKey: [RESOURCES_KEY] });
|
|
},
|
|
onError: (e) => fail(e, 'ذخیرهٔ سرویسها ناموفق بود'),
|
|
});
|
|
|
|
return { offerings: query.data?.data ?? [], loading: query.isLoading, save };
|
|
}
|
|
|
|
export function useResourceTypes() {
|
|
const qc = useQueryClient();
|
|
const invalidate = () => qc.invalidateQueries({ queryKey: TYPES_KEY });
|
|
|
|
const query = useQuery({
|
|
queryKey: TYPES_KEY,
|
|
queryFn: () => api.get<ApiResponse<ResourceType[]>>('/api/v1/resource-types'),
|
|
});
|
|
|
|
const create = useMutation({
|
|
mutationFn: (d: { code: string; name: string }) =>
|
|
api.post<ApiResponse<ResourceType>>('/api/v1/resource-types', d),
|
|
onSuccess: () => { toast.success('نوع منبع افزوده شد'); invalidate(); },
|
|
onError: (e) => fail(e, 'افزودن نوع منبع ناموفق بود'),
|
|
});
|
|
|
|
const update = useMutation({
|
|
mutationFn: ({ uuid, d }: { uuid: string; d: { name?: string; active?: boolean } }) =>
|
|
api.patch<ApiResponse<ResourceType>>(`/api/v1/resource-type/${uuid}`, d),
|
|
onSuccess: () => { toast.success('نوع منبع بهروزرسانی شد'); invalidate(); },
|
|
onError: (e) => fail(e, 'بهروزرسانی ناموفق بود'),
|
|
});
|
|
|
|
const remove = useMutation({
|
|
mutationFn: (uuid: string) => api.delete<ApiResponse<null>>(`/api/v1/resource-type/${uuid}`),
|
|
onSuccess: () => { toast.success('نوع منبع حذف شد'); invalidate(); },
|
|
onError: (e) => fail(e, 'حذف نوع منبع ناموفق بود'),
|
|
});
|
|
|
|
return { types: query.data?.data ?? [], loading: query.isLoading, create, update, remove };
|
|
}
|
|
|
|
export function useSkills() {
|
|
const qc = useQueryClient();
|
|
const invalidate = () => qc.invalidateQueries({ queryKey: SKILLS_KEY });
|
|
|
|
const query = useQuery({
|
|
queryKey: SKILLS_KEY,
|
|
queryFn: () => api.get<ApiResponse<Skill[]>>('/api/v1/skills'),
|
|
});
|
|
|
|
const create = useMutation({
|
|
mutationFn: (d: { name: string }) => api.post<ApiResponse<Skill>>('/api/v1/skills', d),
|
|
onSuccess: () => { toast.success('مهارت افزوده شد'); invalidate(); },
|
|
onError: (e) => fail(e, 'افزودن مهارت ناموفق بود'),
|
|
});
|
|
|
|
const update = useMutation({
|
|
mutationFn: ({ uuid, d }: { uuid: string; d: { name?: string; active?: boolean } }) =>
|
|
api.patch<ApiResponse<Skill>>(`/api/v1/skill/${uuid}`, d),
|
|
onSuccess: () => { toast.success('مهارت بهروزرسانی شد'); invalidate(); },
|
|
onError: (e) => fail(e, 'بهروزرسانی ناموفق بود'),
|
|
});
|
|
|
|
const remove = useMutation({
|
|
mutationFn: (uuid: string) => api.delete<ApiResponse<null>>(`/api/v1/skill/${uuid}`),
|
|
onSuccess: () => { toast.success('مهارت حذف شد'); invalidate(); },
|
|
// پیام سرور دقیق است («به N منبع داده شده»)، پس همان نشان داده میشود.
|
|
onError: (e) => fail(e, 'حذف مهارت ناموفق بود'),
|
|
});
|
|
|
|
return { skills: query.data?.data ?? [], loading: query.isLoading, create, update, remove };
|
|
}
|
|
|
|
export function useResourcePools() {
|
|
const qc = useQueryClient();
|
|
const invalidate = () => qc.invalidateQueries({ queryKey: POOLS_KEY });
|
|
|
|
const query = useQuery({
|
|
queryKey: POOLS_KEY,
|
|
queryFn: () => api.get<ApiResponse<ResourcePool[]>>('/api/v1/resource-pools'),
|
|
});
|
|
|
|
const create = useMutation({
|
|
// آدرس فرستاده نمیشود: منابع دامنهٔ شعبه ندارند و سرور آدرسِ خودِ محیط را برمیدارد.
|
|
mutationFn: (d: { type_uuid: string; name: string }) =>
|
|
api.post<ApiResponse<ResourcePool>>('/api/v1/resource-pools', d),
|
|
onSuccess: () => { toast.success('استخر افزوده شد'); invalidate(); },
|
|
onError: (e) => fail(e, 'افزودن استخر ناموفق بود'),
|
|
});
|
|
|
|
const update = useMutation({
|
|
mutationFn: ({ uuid, d }: { uuid: string; d: { name?: string; active?: boolean } }) =>
|
|
api.patch<ApiResponse<ResourcePool>>(`/api/v1/resource-pool/${uuid}`, d),
|
|
onSuccess: () => { toast.success('استخر بهروزرسانی شد'); invalidate(); },
|
|
onError: (e) => fail(e, 'بهروزرسانی ناموفق بود'),
|
|
});
|
|
|
|
const remove = useMutation({
|
|
mutationFn: (uuid: string) => api.delete<ApiResponse<null>>(`/api/v1/resource-pool/${uuid}`),
|
|
onSuccess: () => { toast.success('استخر حذف شد'); invalidate(); },
|
|
onError: (e) => fail(e, 'حذف استخر ناموفق بود'),
|
|
});
|
|
|
|
/** جایگزینی کامل اعضا؛ سرور همشعبه و همنوع بودن را اجبار میکند. */
|
|
const setMembers = useMutation({
|
|
mutationFn: ({ uuid, members }: { uuid: string; members: { resource_uuid: string; priority: number }[] }) =>
|
|
api.put<ApiResponse<ResourcePool>>(`/api/v1/resource-pool/${uuid}/members`, { members }),
|
|
onSuccess: () => { toast.success('اعضای استخر ذخیره شد'); invalidate(); },
|
|
onError: (e) => fail(e, 'ذخیرهٔ اعضا ناموفق بود'),
|
|
});
|
|
|
|
return { pools: query.data?.data ?? [], loading: query.isLoading, create, update, remove, setMembers };
|
|
}
|
|
|
|
/**
|
|
* جزئیات یک منبع — تنها جایی که شمار نوبتهای آیندهٔ آن میآید.
|
|
*
|
|
* در فهرست نمیآید چون آنجا یک کوئری per ردیف میشد؛ اینجا فقط وقتی لازم است که
|
|
* کاربر دارد همان یک منبع را ویرایش میکند.
|
|
*/
|
|
export function useResourceDetail(uuid: string | undefined) {
|
|
const query = useQuery({
|
|
queryKey: [RESOURCE_DETAIL_KEY, uuid],
|
|
queryFn: () =>
|
|
api.get<ApiResponse<ClinicResource & { upcoming_appointments: number }>>(
|
|
`/api/v1/resource/${uuid}`,
|
|
),
|
|
enabled: !!uuid,
|
|
});
|
|
|
|
return {
|
|
resource: query.data?.data,
|
|
upcomingAppointments: query.data?.data?.upcoming_appointments ?? 0,
|
|
loading: query.isLoading,
|
|
};
|
|
}
|