Files
clinicpro/assets/admin/hooks/useResources.ts
T
hamedandClaude Opus 5 000cf70761 feat(resources): warn before switching a resource off, and pick dates in Jalali
The deactivation warning was blocked on task 07: there was no way to count "the
appointments on this resource" until occupancy rows linked the two. They do
now, so GET /resource/{uuid} returns upcoming_appointments. It stays off the
list endpoint, where it would be one count query per row.

It is a warning, not a block, and the wording says so: switching a resource off
does not cancel anything, it only removes the resource from future searches.
The panel shows it the moment the "active" box is unticked.

The calendar's exception range still used <input type="date">, which is
Gregorian. Operators say dates in Jalali, and the mental conversion is exactly
where an exception gets recorded a day off. PersianDateInput takes the same
YYYY-MM-DD string, so this is a drop-in swap.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 13:45:25 +03:30

206 lines
8.5 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, ResourceType, Skill,
} from '../types';
/**
* منابع: هر چیزی که ممکن است اشغال باشد. هر منبع مال یک شعبه است، و شعبه همان
* آدرس محل نوبت‌دهی است — پس فیلترها با `address_uuid` کار می‌کنند نه `branch_id`.
*/
const RESOURCES_KEY = 'resources';
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();
const invalidate = () => qc.invalidateQueries({ queryKey: [RESOURCES_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, 'افزودن منبع ناموفق بود'),
});
const update = useMutation({
mutationFn: ({ uuid, d }: { uuid: string; d: 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, 'ذخیرهٔ مهارت‌ها ناموفق بود'),
});
return {
resources: query.data?.data ?? [],
loading: query.isLoading,
create, update, remove, setSkills,
};
}
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: { address_uuid: string; 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', 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,
};
}