Files
clinicpro/assets/admin/components/ServiceCategoryTab.tsx
T
hamed 6876135a53 feat: add BlogBodySanitizer for HTML sanitization on article save
- Implemented BlogBodySanitizer to clean HTML content before saving articles, ensuring security against XSS attacks.
- Added tests for BlogBodySanitizer to verify that unsafe tags and attributes are stripped from the content.
- Introduced ApiLeastPrivilegeTest to ensure that unauthorized users cannot access sensitive API routes, maintaining strict access control.
2026-08-07 21:13:38 +03:30

108 lines
4.6 KiB
TypeScript

import React, { useEffect, useMemo, useState } from 'react';
import { Link } from 'react-router';
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>
);
}