Files
clinicpro/assets/admin/hooks/useResources.ts
T
hamedandClaude Opus 5 d8aabe5d0a feat(admin): treatment plan tab, staff session screens and the device form editor
Three screens, each reusing what already exists rather than inventing a parallel
look. The treatment plan lives as a tab on the service page next to categories,
because the course belongs to the service; the switch is the protocol's existence
rather than a separate boolean that could disagree with the step list. Each step
asks for days since the previous session, which is how the interval actually
works and what the form should therefore say.

The staff screens are the flow from the reference screenshots: today's sessions,
then a session where each area is started, recorded and closed on its own. Field
inputs are built from the schema the server sends per resource type, so a clinic
adding an RF device sees its own form here without a code change.

StatusBadge gains treatment session and area states rather than a second badge
component sitting beside it, and the resource type modal grows a field editor so
the operator form is configured where the device is defined.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-06 18:29:49 +03:30

282 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 { useSubscription } from './useSubscription';
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, 'ذخیرهٔ مهارت‌ها ناموفق بود'),
});
return {
resources: query.data?.data ?? [],
loading: query.isLoading,
create, update, remove, setSkills,
};
}
/**
* سهمیهٔ منابع محیط — سقفش از پلن اشتراک می‌آید و مصرفش از فهرست منابع.
*
* فهرست عمداً بی‌فیلتر خوانده می‌شود: سهمیه به کل محیط مربوط است و شمردنِ ردیف‌های
* فیلترشدهٔ صفحه، با هر فیلتر یک عدد متفاوت می‌داد. کوئری با کلیدِ `[resources, {}]`
* همان کوئریِ حالتِ بی‌فیلترِ صفحه است، پس معمولاً رفت‌وبرگشت اضافه‌ای نمی‌سازد.
*
* تصمیم نهایی با سرور است؛ این فقط دکمه را پیش از رفتن به فرم می‌بندد.
*/
export function useResourceQuota() {
const { maxResources, planLoaded } = useSubscription();
const { data } = useQuery({
queryKey: [RESOURCES_KEY, {}],
queryFn: () => api.get<ApiResponse<ClinicResource[]>>('/api/v1/resources'),
});
const used = data?.data?.length ?? 0;
// سقفِ ناشناخته (نقش بی‌اشتراک، یا پاسخِ هنوز نرسیده) گیت نمی‌شود؛ سرور خودش
// ۴۲۲ می‌دهد و بستنِ دکمه بر اساس حدس، بدتر از بستنش دیرتر است.
const unlimited = !planLoaded || maxResources < 0;
return {
used,
limit: maxResources,
unlimited,
atLimit: !unlimited && used >= maxResources,
};
}
/**
* سرویس‌هایی که یک منبع ارائه می‌دهد.
*
* مقدار مؤثر و منبعِ هر عدد از سرور می‌آید، نه از محاسبهٔ فرانت: زنجیرهٔ حل چهار سطح
* دارد و بازسازی‌اش اینجا یعنی دو پیاده‌سازی که با هم واگرا می‌شوند.
*/
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; field_schema?: unknown }) =>
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; field_schema?: unknown } }) =>
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,
};
}