Files
clinicpro/assets/admin/hooks/useResources.ts
T
hamedandClaude Opus 5 26425bec31 Manage a resource's services from the panel
A "سرویس‌ها" action on each resource row opens a modal listing what that
resource performs, with its own duration and price. It follows the skills modal
exactly — same PUT-replaces-everything contract, same components, no new page
and no new route.

Leaving a cell empty means inherit, so the effective value is shown as the
placeholder along with where it came from: "40 — service default", "1,800,000
toman — branch". Without that the user cannot tell an unset field from a zero,
which is the one thing this screen has to communicate.

Two backend adjustments came out of wiring it up:

- the offering filter in findEligible is now scoped to the requirement's
  resource type. Registering lasers for a service was making the room in the
  same plan ineligible and breaking the whole booking — "who performs this" is
  about the performing role, not about rooms and support resources. The seeder
  caught this immediately.
- the scenario seeder now creates offerings and resource categories, so the
  demo data exercises this model instead of leaving every resource empty.

Three vitest tests: inherited value with its source, saving an override, and
clearing back to inheritance. Verified in the browser at 1440 dark, 1440
compact and 390 mobile — the last with no horizontal scroll.

Panel suite 648 green across 98 files, tsc clean, encore build succeeds.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 22:57:55 +03:30

236 lines
9.9 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';
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 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: { 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,
};
}