feat(services): pick a global category from the service page
The service page could not say which category a service belongs to, so the
containment edges defined in settings had nothing to match against.
A Categories tab now selects one — and only selects. Creating, renaming and
deleting stay in Settings > Categories: if every page could create one,
"whole body" would exist three times with three spellings and the
includes edge would stop catching anything.
PATCH /api/v1/service-item/{uuid} carries the choice as
catalog_category_uuid. Absent field leaves the current category alone, null
clears it, and a category from another environment is refused with 422 —
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:
@@ -0,0 +1,107 @@
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { toast } from 'sonner';
|
||||
import SearchableSelect from './ui/SearchableSelect';
|
||||
import { api, ApiError, type ApiResponse } from '../lib/api';
|
||||
import { useCatalogCategories, useCategoryIncludes } from '../hooks/useCatalogCategories';
|
||||
import type { CatalogCategory } from '../types';
|
||||
|
||||
type Flat = { uuid: string; name: string; depth: number };
|
||||
|
||||
function flatten(nodes: CatalogCategory[], depth = 0): Flat[] {
|
||||
return nodes.flatMap((n) => [
|
||||
{ uuid: n.uuid, name: n.name, depth },
|
||||
...flatten(n.children ?? [], depth + 1),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* دستهٔ یک سرویس — فقط **انتخاب** از کاتالوگ سراسری.
|
||||
*
|
||||
* ساخت، ویرایش و حذف دسته عمداً اینجا نیست؛ فقط در «تنظیمات ← دستهبندیها». اگر هر
|
||||
* صفحهای بتواند دسته بسازد، «تمام بدن» چند بار با املاهای مختلف ساخته میشود و یال
|
||||
* «شامل بودن» دیگر چیزی را نمیگیرد.
|
||||
*/
|
||||
export default function ServiceCategoryTab({ serviceUuid, categoryUuid, canEdit }: {
|
||||
serviceUuid: string;
|
||||
categoryUuid: string | null;
|
||||
canEdit: boolean;
|
||||
}) {
|
||||
const qc = useQueryClient();
|
||||
const { tree, loading } = useCatalogCategories();
|
||||
const [selected, setSelected] = useState<string | null>(categoryUuid);
|
||||
|
||||
useEffect(() => setSelected(categoryUuid), [categoryUuid]);
|
||||
|
||||
const all = useMemo(() => flatten(tree), [tree]);
|
||||
const { includes } = useCategoryIncludes(selected ?? undefined);
|
||||
|
||||
const save = useMutation({
|
||||
mutationFn: (uuid: string | null) =>
|
||||
api.patch<ApiResponse<unknown>>(`/api/v1/service-item/${serviceUuid}`, { catalog_category_uuid: uuid }),
|
||||
onSuccess: () => {
|
||||
toast.success('دستهبندی سرویس ذخیره شد');
|
||||
qc.invalidateQueries({ queryKey: ['service-item', serviceUuid] });
|
||||
},
|
||||
onError: (e) => toast.error(e instanceof ApiError ? e.message : 'ذخیرهٔ دستهبندی ناموفق بود'),
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="card card-pad" style={{ display: 'grid', gap: 14 }}>
|
||||
<p style={{ margin: 0, fontSize: 12.5, color: 'var(--text-3)', lineHeight: 1.9 }}>
|
||||
دستهبندی سراسری کلینیک است و اینجا فقط انتخاب میشود. ساخت، ویرایش و حذف فقط در{' '}
|
||||
<Link to="/admin/service-categories" style={{ color: 'var(--primary)' }}>تنظیمات ← دستهبندیها</Link>{' '}
|
||||
انجام میشود.
|
||||
</p>
|
||||
|
||||
{loading ? (
|
||||
<span style={{ fontSize: 13, color: 'var(--text-3)' }}>در حال بارگذاری...</span>
|
||||
) : all.length === 0 ? (
|
||||
<p style={{ fontSize: 13, color: 'var(--text-3)', margin: 0 }}>
|
||||
هنوز هیچ دستهبندیای تعریف نشده است.
|
||||
</p>
|
||||
) : (
|
||||
<div style={{ display: 'grid', gap: 6, maxWidth: 380 }}>
|
||||
<label style={{ fontSize: 12, color: 'var(--text-2)' }}>دستهبندی این سرویس</label>
|
||||
<SearchableSelect
|
||||
options={all.map((c) => ({ value: c.uuid, label: '— '.repeat(c.depth) + c.name }))}
|
||||
value={selected}
|
||||
onChange={(v) => setSelected(v ? String(v) : null)}
|
||||
placeholder="بدون دستهبندی"
|
||||
isDisabled={!canEdit}
|
||||
isClearable
|
||||
height={38}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selected && includes.length > 0 && (
|
||||
<div style={{ display: 'grid', gap: 6 }}>
|
||||
<span style={{ fontSize: 12, color: 'var(--text-2)' }}>این دسته شامل:</span>
|
||||
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap' }}>
|
||||
{includes.map((c) => (
|
||||
<span key={c.uuid} className="badge gray" style={{ fontSize: 11 }}>{c.name}</span>
|
||||
))}
|
||||
</div>
|
||||
<span style={{ fontSize: 12, color: 'var(--text-3)' }}>
|
||||
انتخاب همزمان این سرویس با سرویسی از این زیرمجموعهها هنگام رزرو رد میشود.
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{canEdit && (
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end' }}>
|
||||
<button
|
||||
type="button"
|
||||
className="btn primary"
|
||||
disabled={save.isPending || selected === categoryUuid}
|
||||
onClick={() => save.mutate(selected)}
|
||||
>
|
||||
{save.isPending ? 'در حال ذخیره...' : 'ذخیره'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user