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>
236 lines
9.9 KiB
TypeScript
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,
|
|
};
|
|
}
|