Files
clinicpro/assets/admin/hooks/useServiceCatalog.ts
T
hamedandClaude Opus 5 26a8e53b34 feat(admin): catalog groups and appointment segments editors
Tasks 04 and 05 shipped working engines that a clinic could only reach through
the API. Both now have the panel that makes them usable.

Groups tab
- Inline min/max per group, saved on blur, with the meaning of an empty maximum
  spelled out next to the field rather than left as folklore
- Incompatible / prerequisite rows; the prerequisite-cycle 422 surfaces the
  server's own message, which is more precise than anything generic
- A live preview that calls the same service-selection/validate the public site
  calls, debounced 400ms. Two separate calculations would eventually show the
  operator and the patient different numbers
- The breakdown table shows which item was counted as the anchor and which as
  additional, so a surprising total explains itself

Segments tab
- Sequence, duration source, patient-present and mergeable per segment, plus
  resource requirements with an explanation attached to each occupancy mode
- A timeline bar whose widths are proportional to duration, with segments the
  patient is absent for drawn faded. That contrast is the whole point of task
  05: the waiting segment holds the room but frees the operator
- "No eligible resource" renders with a link to add one — an error with no
  route forward is a dead end

Task 05's checklist had been left on "not started" this whole time even though
its code shipped with the task; it is now filled in against reality.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 19:42:24 +03:30

154 lines
5.6 KiB
TypeScript

import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { toast } from 'sonner';
import { api, ApiError, type ApiResponse } from '../lib/api';
import type { ServiceItem } from '../types';
/**
* کاتالوگ v2 — گروه انتخاب، رابطهٔ آیتم‌ها، و اعتبارسنجی زندهٔ انتخاب.
*
* `validate` هم مدت و قیمت را می‌دهد هم خطاها؛ همان اندپوینتی که سایت عمومی می‌زند.
* پیش‌نمایش فرم از **همان** مسیر می‌آید تا عددی که اپراتور می‌بیند با عددی که بیمار
* می‌بیند یکی باشد.
*/
export interface ItemGroup {
uuid: string;
name: string;
min_select: number;
/** `null` یعنی نامحدود — نه صفر */
max_select: number | null;
items: { uuid: string; name: string }[];
}
export interface ItemRelation {
related_item_uuid: string;
related_item_name?: string;
type: 'incompatible_with' | 'requires';
}
export interface SelectionBreakdownRow {
item_uuid: string;
item_name: string;
counted_as: 'solo' | 'additional';
minutes: number;
price_rials: number;
}
export interface SelectionValidation {
valid: boolean;
errors: { code: string; message: string; group_uuid?: string; items?: string[] }[];
total_duration_minutes: number;
total_price_rials: number;
breakdown: SelectionBreakdownRow[];
}
function fail(e: unknown, fallback: string) {
toast.error(e instanceof ApiError ? e.message : fallback);
}
export function useServiceGroups(serviceUuid: string | undefined) {
const qc = useQueryClient();
const key = ['service-groups', serviceUuid];
const query = useQuery({
queryKey: key,
queryFn: () => api.get<ApiResponse<ItemGroup[]>>(`/api/v1/service-item/${serviceUuid}/groups`),
enabled: !!serviceUuid,
});
const invalidate = () => qc.invalidateQueries({ queryKey: key });
const create = useMutation({
mutationFn: (body: { name: string; min_select: number; max_select: number | null }) =>
api.post<ApiResponse<ItemGroup>>(`/api/v1/service-item/${serviceUuid}/groups`, body),
onSuccess: () => {
toast.success('گروه ساخته شد');
invalidate();
},
onError: (e) => fail(e, 'ساخت گروه ناموفق بود'),
});
const update = useMutation({
mutationFn: ({ uuid, body }: { uuid: string; body: Record<string, unknown> }) =>
api.patch<ApiResponse<ItemGroup>>(`/api/v1/item-group/${uuid}`, body),
onSuccess: () => {
toast.success('گروه به‌روزرسانی شد');
invalidate();
},
onError: (e) => fail(e, 'به‌روزرسانی گروه ناموفق بود'),
});
const remove = useMutation({
mutationFn: (uuid: string) => api.delete<ApiResponse<null>>(`/api/v1/item-group/${uuid}`),
onSuccess: () => {
toast.success('گروه حذف شد');
invalidate();
},
onError: (e) => fail(e, 'حذف گروه ناموفق بود'),
});
/** جایگزینی کامل — قرارداد `PUT` روی زیرمجموعه. */
const setItems = useMutation({
mutationFn: ({ uuid, itemUuids }: { uuid: string; itemUuids: string[] }) =>
api.put<ApiResponse<ItemGroup>>(`/api/v1/item-group/${uuid}/items`, { item_uuids: itemUuids }),
onSuccess: () => {
toast.success('آیتم‌های گروه ذخیره شد');
invalidate();
},
onError: (e) => fail(e, 'ذخیرهٔ آیتم‌ها ناموفق بود'),
});
return { groups: query.data?.data ?? [], loading: query.isLoading, create, update, remove, setItems };
}
export function useServiceRelations(serviceUuid: string | undefined) {
const qc = useQueryClient();
const save = useMutation({
mutationFn: (relations: ItemRelation[]) =>
api.put<ApiResponse<{ relations: ItemRelation[] }>>(
`/api/v1/service-item/${serviceUuid}/relations`,
{ relations: relations.map((r) => ({ related_item_uuid: r.related_item_uuid, type: r.type })) },
),
onSuccess: () => {
toast.success('روابط ذخیره شد');
qc.invalidateQueries({ queryKey: ['service-item', serviceUuid] });
},
// حلقهٔ پیش‌نیاز همین‌جا ۴۲۲ می‌گیرد؛ پیام سرور دقیق‌تر از هر متن عمومی است.
onError: (e) => fail(e, 'ذخیرهٔ روابط ناموفق بود'),
});
return { save };
}
/** فهرست همهٔ آیتم‌های محیط — ورودی انتخابگرهای گروه و رابطه. */
export function useAllServiceItems() {
const query = useQuery({
queryKey: ['service-items-all'],
queryFn: () => api.get<ApiResponse<ServiceItem[]>>('/api/v1/service-items'),
staleTime: 60_000,
});
return { items: query.data?.data ?? [], loading: query.isLoading };
}
/**
* اعتبارسنجی زندهٔ یک انتخاب.
*
* `enabled` روی انتخاب خالی خاموش است: فراخوانی بدون آیتم فقط نویز شبکه‌ای است و
* خطای «حداقل انتخاب» را هم بی‌جا نشان می‌دهد.
*/
export function useSelectionPreview(itemUuids: string[], serviceUuid?: string, branchUuid?: string) {
const query = useQuery({
queryKey: ['selection-preview', itemUuids, serviceUuid, branchUuid],
queryFn: () =>
api.post<ApiResponse<SelectionValidation>>('/api/v1/service-selection/validate', {
item_uuids: itemUuids,
...(serviceUuid ? { service_uuid: serviceUuid } : {}),
...(branchUuid ? { branch_uuid: branchUuid } : {}),
}),
enabled: itemUuids.length > 0,
});
return { preview: query.data?.data, loading: query.isFetching };
}