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:
@@ -0,0 +1,80 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { renderWithProviders } from '../test/utils';
|
||||
|
||||
vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn() } }));
|
||||
vi.mock('../hooks/usePermissions', () => ({ usePermissions: () => ({ can: () => true }) }));
|
||||
|
||||
const create = { mutate: vi.fn(), isPending: false };
|
||||
const update = { mutate: vi.fn(), isPending: false };
|
||||
const remove = { mutate: vi.fn(), isPending: false };
|
||||
const addEdge = { mutate: vi.fn(), isPending: false };
|
||||
const removeEdge = { mutate: vi.fn(), isPending: false };
|
||||
|
||||
const tree = [
|
||||
{
|
||||
uuid: 'c-whole',
|
||||
name: 'تمام بدن',
|
||||
sort_order: 0,
|
||||
active: true,
|
||||
children: [{ uuid: 'c-hand', name: 'دست', sort_order: 1, active: true, children: [] }],
|
||||
},
|
||||
];
|
||||
|
||||
let includes: Array<{ uuid: string; name: string }> = [];
|
||||
|
||||
vi.mock('../hooks/useCatalogCategories', () => ({
|
||||
useCatalogCategories: () => ({ tree, loading: false, create, update, remove }),
|
||||
useCategoryIncludes: () => ({ includes, loading: false, add: addEdge, remove: removeEdge }),
|
||||
}));
|
||||
|
||||
import CatalogCategoriesPage from './CatalogCategoriesPage';
|
||||
|
||||
describe('CatalogCategoriesPage', () => {
|
||||
beforeEach(() => {
|
||||
includes = [];
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('درخت را تخت نشان میدهد — زیردسته هم ردیف خودش را دارد', () => {
|
||||
renderWithProviders(<CatalogCategoriesPage />);
|
||||
|
||||
expect(screen.getByText('تمام بدن')).toBeInTheDocument();
|
||||
expect(screen.getByText('دست')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('ساخت دسته، نام را به API میدهد', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithProviders(<CatalogCategoriesPage />);
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /افزودن دستهبندی/ }));
|
||||
await user.type(screen.getByLabelText('نام دستهبندی'), 'پا');
|
||||
await user.click(screen.getByRole('button', { name: 'ذخیره' }));
|
||||
|
||||
expect(create.mutate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ name: 'پا' }),
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
it('دکمهٔ ذخیره با نام خالی غیرفعال است', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithProviders(<CatalogCategoriesPage />);
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /افزودن دستهبندی/ }));
|
||||
|
||||
expect(screen.getByRole('button', { name: 'ذخیره' })).toBeDisabled();
|
||||
});
|
||||
|
||||
it('یال «شامل بودن» را با دکمهٔ حذف برمیدارد', async () => {
|
||||
includes = [{ uuid: 'c-hand', name: 'دست' }];
|
||||
const user = userEvent.setup();
|
||||
renderWithProviders(<CatalogCategoriesPage />);
|
||||
|
||||
await user.click(screen.getAllByRole('button', { name: 'شامل' })[0]);
|
||||
await user.click(screen.getByRole('button', { name: 'حذف دست' }));
|
||||
|
||||
expect(removeEdge.mutate).toHaveBeenCalledWith({ uuid: 'c-whole', childUuid: 'c-hand' });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,292 @@
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import { PlusIcon } from '@heroicons/react/24/outline';
|
||||
import PageHeader from '../components/ui/PageHeader';
|
||||
import DataTable, { type Column } from '../components/ui/DataTable';
|
||||
import Modal from '../components/ui/Modal';
|
||||
import ConfirmDialog from '../components/ui/ConfirmDialog';
|
||||
import SearchableSelect from '../components/ui/SearchableSelect';
|
||||
import { ActiveBadge } from '../components/ui/StatusBadge';
|
||||
import { useUrlState } from '../hooks/useUrlState';
|
||||
import { usePermissions } from '../hooks/usePermissions';
|
||||
import { useCatalogCategories, useCategoryIncludes } from '../hooks/useCatalogCategories';
|
||||
import type { CatalogCategory } from '../types';
|
||||
|
||||
/** یک ردیف از درخت، صافشده — با عمق، تا تورفتگی نشان دهد کجای درخت است. */
|
||||
type Row = CatalogCategory & { depth: number };
|
||||
|
||||
function flatten(nodes: CatalogCategory[], depth = 0): Row[] {
|
||||
return nodes.flatMap((n) => [{ ...n, depth }, ...flatten(n.children ?? [], depth + 1)]);
|
||||
}
|
||||
|
||||
/**
|
||||
* دستهبندی سراسری کلینیک — تنها جایی که دسته ساخته، ویرایش و حذف میشود.
|
||||
*
|
||||
* صفحهٔ سرویس و منبع فقط از همینها **انتخاب** میکنند؛ ساختن دسته از آنجا عمداً ممکن
|
||||
* نیست، وگرنه هر کاربر دستهٔ تکراری با املای خودش میسازد و «تمام بدن» دو بار وجود
|
||||
* خواهد داشت.
|
||||
*/
|
||||
export default function CatalogCategoriesPage() {
|
||||
const { tree, loading, create, update, remove } = useCatalogCategories();
|
||||
const { can } = usePermissions();
|
||||
const canUpdate = can('appointment_settings', 'update');
|
||||
|
||||
const [urlState, setUrlState] = useUrlState({ search: '' });
|
||||
const [editing, setEditing] = useState<{ open: boolean; category: CatalogCategory | null }>({ open: false, category: null });
|
||||
const [includesFor, setIncludesFor] = useState<CatalogCategory | null>(null);
|
||||
const [toDelete, setToDelete] = useState<CatalogCategory | null>(null);
|
||||
|
||||
const all = useMemo(() => flatten(tree), [tree]);
|
||||
|
||||
const rows = useMemo(() => {
|
||||
const q = urlState.search.trim();
|
||||
return q === '' ? all : all.filter((c) => c.name.includes(q));
|
||||
}, [all, urlState.search]);
|
||||
|
||||
const columns: Column<Row>[] = [
|
||||
{
|
||||
key: 'name',
|
||||
header: 'دستهبندی',
|
||||
render: (c) => (
|
||||
<span style={{ paddingRight: c.depth * 18, fontWeight: c.depth === 0 ? 600 : 400 }}>
|
||||
{c.depth > 0 && <span style={{ color: 'var(--text-3)' }}>└ </span>}
|
||||
{c.name}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{ key: 'sort_order', header: 'ترتیب', render: (c) => <span style={{ fontSize: 13 }}>{c.sort_order ?? 0}</span> },
|
||||
{ key: 'active', header: 'وضعیت', render: (c) => <ActiveBadge active={c.active} /> },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="fade-in">
|
||||
<PageHeader
|
||||
title="دستهبندیها"
|
||||
description="دستهبندی سراسری کلینیک؛ یک بار تعریف میشود و سرویسها و منابع از همینها انتخاب میکنند."
|
||||
backTo="/admin/settings-menu"
|
||||
action={
|
||||
canUpdate ? (
|
||||
<button type="button" className="btn primary" onClick={() => setEditing({ open: true, category: null })}>
|
||||
<PlusIcon style={{ width: 16 }} /> افزودن دستهبندی
|
||||
</button>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
|
||||
<DataTable<Row>
|
||||
columns={columns}
|
||||
data={rows}
|
||||
loading={loading}
|
||||
searchValue={urlState.search}
|
||||
onSearchChange={(v) => setUrlState({ search: v })}
|
||||
searchPlaceholder="جستجو در دستهبندیها..."
|
||||
emptyMessage="هنوز دستهبندیای تعریف نشده است"
|
||||
actions={
|
||||
canUpdate
|
||||
? (c) => (
|
||||
<div style={{ display: 'flex', gap: 6 }}>
|
||||
<button type="button" className="btn secondary sm" onClick={() => setEditing({ open: true, category: c })}>
|
||||
ویرایش
|
||||
</button>
|
||||
<button type="button" className="btn secondary sm" onClick={() => setIncludesFor(c)}>
|
||||
شامل
|
||||
</button>
|
||||
<button type="button" className="btn secondary sm" onClick={() => setToDelete(c)}>
|
||||
حذف
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
|
||||
<CategoryFormModal
|
||||
open={editing.open}
|
||||
category={editing.category}
|
||||
parents={all}
|
||||
saving={create.isPending || update.isPending}
|
||||
onClose={() => setEditing({ open: false, category: null })}
|
||||
onSave={(payload) => {
|
||||
const done = { onSuccess: () => setEditing({ open: false, category: null }) };
|
||||
|
||||
if (editing.category) {
|
||||
update.mutate({ uuid: editing.category.uuid, d: { name: payload.name, sort_order: payload.sort_order, active: payload.active } }, done);
|
||||
} else {
|
||||
create.mutate({ name: payload.name, parent_uuid: payload.parent_uuid }, done);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
<CategoryIncludesModal
|
||||
category={includesFor}
|
||||
all={all}
|
||||
onClose={() => setIncludesFor(null)}
|
||||
/>
|
||||
|
||||
<ConfirmDialog
|
||||
open={!!toDelete}
|
||||
title="حذف دستهبندی"
|
||||
message={`آیا از حذف «${toDelete?.name}» مطمئن هستید؟ سرویسها و منابعی که این دسته را دارند بدون دسته میمانند.`}
|
||||
confirmLabel="حذف"
|
||||
danger
|
||||
loading={remove.isPending}
|
||||
onConfirm={() => toDelete && remove.mutate(toDelete.uuid, { onSuccess: () => setToDelete(null) })}
|
||||
onCancel={() => setToDelete(null)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CategoryFormModal({
|
||||
open, category, parents, saving, onClose, onSave,
|
||||
}: {
|
||||
open: boolean;
|
||||
category: CatalogCategory | null;
|
||||
parents: Row[];
|
||||
saving: boolean;
|
||||
onClose: () => void;
|
||||
onSave: (payload: { name: string; parent_uuid: string | null; sort_order: number; active: boolean }) => void;
|
||||
}) {
|
||||
const [name, setName] = useState('');
|
||||
const [parent, setParent] = useState<string | null>(null);
|
||||
const [sortOrder, setSortOrder] = useState('0');
|
||||
const [active, setActive] = useState(true);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!open) return;
|
||||
setName(category?.name ?? '');
|
||||
setParent(null);
|
||||
setSortOrder(String(category?.sort_order ?? 0));
|
||||
setActive(category?.active ?? true);
|
||||
}, [open, category]);
|
||||
|
||||
return (
|
||||
<Modal open={open} onClose={onClose} title={category ? `ویرایش «${category.name}»` : 'افزودن دستهبندی'}>
|
||||
<div style={{ display: 'grid', gap: 14 }}>
|
||||
<div className="field-block">
|
||||
<label htmlFor="category-name">نام دستهبندی</label>
|
||||
<input
|
||||
id="category-name"
|
||||
className="field"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="مثلاً: تمام بدن"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{!category && (
|
||||
<div style={{ display: 'grid', gap: 6 }}>
|
||||
<label style={{ fontSize: 12, color: 'var(--text-2)' }}>زیرمجموعهٔ (اختیاری)</label>
|
||||
<SearchableSelect
|
||||
options={parents.map((p) => ({ value: p.uuid, label: '— '.repeat(p.depth) + p.name }))}
|
||||
value={parent}
|
||||
onChange={(v) => setParent(v ? String(v) : null)}
|
||||
placeholder="بدون والد — دستهٔ سطح اول"
|
||||
height={38}
|
||||
/>
|
||||
<span style={{ fontSize: 12, color: 'var(--text-3)' }}>
|
||||
این فقط جای دسته در منوست. «شامل بودن» — مثل «تمام بدن شامل دست» — از دکمهٔ «شامل» تعریف میشود.
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{ display: 'flex', gap: 10 }}>
|
||||
<div className="field-block" style={{ flex: 1 }}>
|
||||
<label htmlFor="category-sort">ترتیب نمایش</label>
|
||||
<input
|
||||
id="category-sort"
|
||||
className="field"
|
||||
inputMode="numeric"
|
||||
value={sortOrder}
|
||||
onChange={(e) => setSortOrder(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<label style={{ display: 'flex', alignItems: 'center', gap: 8, fontSize: 13, color: 'var(--text-2)' }}>
|
||||
<input type="checkbox" checked={active} onChange={(e) => setActive(e.target.checked)} />
|
||||
فعال
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 8 }}>
|
||||
<button type="button" className="btn secondary" onClick={onClose}>انصراف</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn primary"
|
||||
disabled={saving || name.trim() === ''}
|
||||
onClick={() => onSave({
|
||||
name: name.trim(),
|
||||
parent_uuid: parent,
|
||||
sort_order: Number(sortOrder) || 0,
|
||||
active,
|
||||
})}
|
||||
>
|
||||
{saving ? 'در حال ذخیره...' : 'ذخیره'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
/** «این دسته شامل کدام دستههاست» — گراف، جدا از درختِ منو. */
|
||||
function CategoryIncludesModal({
|
||||
category, all, onClose,
|
||||
}: {
|
||||
category: CatalogCategory | null;
|
||||
all: Row[];
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const { includes, add, remove } = useCategoryIncludes(category?.uuid);
|
||||
|
||||
const chosen = new Set(includes.map((c) => c.uuid));
|
||||
const available = all.filter((c) => c.uuid !== category?.uuid && !chosen.has(c.uuid));
|
||||
|
||||
return (
|
||||
<Modal open={category !== null} onClose={onClose} title={`«${category?.name ?? ''}» شامل چه دستههایی است`}>
|
||||
<div style={{ display: 'grid', gap: 14 }}>
|
||||
<p style={{ fontSize: 13, color: 'var(--text-3)', margin: 0, lineHeight: 1.9 }}>
|
||||
انتخاب همزمان دو سرویس که دستهٔ یکی دیگری را در بر میگیرد، هنگام رزرو رد میشود —
|
||||
مثلاً «لیزر تمام بدن» با «لیزر دست».
|
||||
</p>
|
||||
|
||||
{includes.length === 0 ? (
|
||||
<p style={{ fontSize: 13, color: 'var(--text-3)', margin: 0 }}>این دسته زیرمجموعهای ندارد.</p>
|
||||
) : (
|
||||
<div style={{ display: 'grid', gap: 8 }}>
|
||||
{includes.map((child) => (
|
||||
<div key={child.uuid} style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
||||
<span style={{ flex: 1, fontSize: 13, fontWeight: 600 }}>{child.name}</span>
|
||||
<button
|
||||
type="button"
|
||||
className="btn secondary sm"
|
||||
disabled={remove.isPending}
|
||||
onClick={() => category && remove.mutate({ uuid: category.uuid, childUuid: child.uuid })}
|
||||
aria-label={`حذف ${child.name}`}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{available.length > 0 && (
|
||||
<div style={{ display: 'grid', gap: 6 }}>
|
||||
<label style={{ fontSize: 12, color: 'var(--text-2)' }}>افزودن زیرمجموعه</label>
|
||||
<SearchableSelect
|
||||
options={available.map((c) => ({ value: c.uuid, label: '— '.repeat(c.depth) + c.name }))}
|
||||
value={null}
|
||||
onChange={(v) => v && category && add.mutate({ uuid: category.uuid, childUuid: String(v) })}
|
||||
placeholder="یک دسته انتخاب کنید"
|
||||
height={38}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end' }}>
|
||||
<button type="button" className="btn secondary" onClick={onClose}>بستن</button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user