feat(catalog): manage global categories from settings

Categories are the taxonomy both services and resources select from, but
until now they could only be reached through the service catalog, so every
environment ended up with its own spelling of "whole body".

- Settings > Categories page: global CRUD plus the "includes" edge
- POST/GET/DELETE /api/v1/service-category/{uuid}/includes — a DAG, kept
  separate from `parent` because "hand" sits under both "whole body" and
  "upper limb"; a cycle is refused with 422
- PUT /api/v1/resource/{uuid}/categories — full replacement, and a category
  from another environment is rejected explicitly since the uuid arrives in
  the request body where TenantFilter does not reach

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
hamed
2026-08-02 12:58:26 +03:30
co-authored by Claude Opus 5
parent f8e8a63ae8
commit 03d8d7d68a
13 changed files with 865 additions and 0 deletions
@@ -0,0 +1,87 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { toast } from 'sonner';
import { api, ApiError, type ApiResponse } from '../lib/api';
import type { CatalogCategory } from '../types';
/**
* دستهٔ **سراسری** کلینیک — یک بار در تنظیمات تعریف می‌شود و سرویس و منبع از همان
* استفاده می‌کنند.
*
* ⚠️ با `useServiceCategories` اشتباه نشود: آن یکی enum نوع خدمت (سرپایی/بستری) است و
* هم‌نام بودنشان تصادفی است.
*/
const KEY = ['catalog-categories'];
function fail(e: unknown, fallback: string) {
toast.error(e instanceof ApiError ? e.message : fallback);
}
export function useCatalogCategories() {
const qc = useQueryClient();
const invalidate = () => qc.invalidateQueries({ queryKey: KEY });
const query = useQuery({
queryKey: KEY,
queryFn: () => api.get<ApiResponse<CatalogCategory[]>>('/api/v1/service-categories/tree'),
staleTime: 30_000,
});
const create = useMutation({
mutationFn: (d: { name: string; parent_uuid?: string | null }) =>
api.post<ApiResponse<CatalogCategory>>('/api/v1/service-category', d),
onSuccess: () => { toast.success('دسته‌بندی افزوده شد'); invalidate(); },
onError: (e) => fail(e, 'افزودن دسته‌بندی ناموفق بود'),
});
const update = useMutation({
mutationFn: ({ uuid, d }: { uuid: string; d: { name?: string; sort_order?: number; active?: boolean } }) =>
api.patch<ApiResponse<CatalogCategory>>(`/api/v1/service-category/${uuid}`, d),
onSuccess: () => { toast.success('دسته‌بندی به‌روزرسانی شد'); invalidate(); },
onError: (e) => fail(e, 'به‌روزرسانی ناموفق بود'),
});
const remove = useMutation({
mutationFn: (uuid: string) => api.delete<ApiResponse<null>>(`/api/v1/service-category/${uuid}`),
// پیام سرور دقیق است («ابتدا زیردسته‌ها را حذف کنید»)، پس همان نشان داده می‌شود.
onSuccess: () => { toast.success('دسته‌بندی حذف شد'); invalidate(); },
onError: (e) => fail(e, 'حذف دسته‌بندی ناموفق بود'),
});
return { tree: query.data?.data ?? [], loading: query.isLoading, create, update, remove };
}
/**
* یال‌های «این دسته شامل آن دسته است».
*
* جدا از `parent` است: آن سلسله‌مراتب نمایشیِ منوست، این می‌گوید انتخاب هم‌زمان دو
* سرویس از این دو دسته تعارض دارد («تمام بدن» و «دست»).
*/
export function useCategoryIncludes(categoryUuid?: string) {
const qc = useQueryClient();
const key = ['category-includes', categoryUuid];
const query = useQuery({
queryKey: key,
queryFn: () => api.get<ApiResponse<CatalogCategory[]>>(`/api/v1/service-category/${categoryUuid}/includes`),
enabled: !!categoryUuid,
});
const invalidate = () => qc.invalidateQueries({ queryKey: ['category-includes'] });
const add = useMutation({
mutationFn: ({ uuid, childUuid }: { uuid: string; childUuid: string }) =>
api.post<ApiResponse<CatalogCategory>>(`/api/v1/service-category/${uuid}/includes`, { child_category_uuid: childUuid }),
onSuccess: () => { toast.success('زیرمجموعه افزوده شد'); invalidate(); },
// پیام سرور می‌گوید کدام دو دسته حلقه می‌سازند.
onError: (e) => fail(e, 'افزودن زیرمجموعه ناموفق بود'),
});
const remove = useMutation({
mutationFn: ({ uuid, childUuid }: { uuid: string; childUuid: string }) =>
api.delete<ApiResponse<null>>(`/api/v1/service-category/${uuid}/includes/${childUuid}`),
onSuccess: () => { toast.success('زیرمجموعه حذف شد'); invalidate(); },
onError: (e) => fail(e, 'حذف زیرمجموعه ناموفق بود'),
});
return { includes: query.data?.data ?? [], loading: query.isLoading, add, remove };
}