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>
154 lines
5.6 KiB
TypeScript
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 };
|
|
}
|