feat(permissions): render both permission forms from the catalog, fix borrowed gates

The three hardcoded resource lists in the admin panel are gone. MySecretariesPage,
SecretariesPage and DoctorPermissionsModal now render from
GET /api/v1/permission-catalog, so a resource added to the backend registry shows
up in all of them with no frontend change. Each has a test that proves exactly
that by adding a resource to the mock and asserting it renders.

SecretaryPermissions was an interface with a field per resource, which made
"dynamic" impossible in TypeScript — every new resource would have been a compile
error. It is now an open map. Only two files consumed it.

The borrowed gates are corrected:
- five resource pages moved off appointment_settings onto their own 'resources'
- treatment-cases moved off appointments onto 'treatment'
- service-categories moved onto 'services', which is what ServiceCatalogController
  actually manages (categories, item groups, service relations) — not resources

TreatmentCaseController had no permission gate at all, only IS_AUTHENTICATED_FULLY,
so any secretary could read and edit treatment cases. All seven of its actions are
now gated on treatment view/update.

ResourcePermissionTrait takes the resource from an overridable method instead of
hardcoding appointment_settings. HolidayController overrides it back, since the
holidays page really is appointment settings. The booking gate keeps its
appointments.view fallback so a secretary who may book is not blocked by a
resource-config permission.

Defaults were picked to preserve today's effective access, so no role gains or
loses a page from this move.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
hamed
2026-08-07 18:11:05 +03:30
co-authored by Claude Opus 5
parent dc40651308
commit ddd5f8f75a
17 changed files with 389 additions and 536 deletions
+1 -1
View File
@@ -30,7 +30,7 @@ function flatten(nodes: CatalogCategory[], depth = 0): Row[] {
export default function CatalogCategoriesPage() {
const { tree, loading, create, update, remove } = useCatalogCategories();
const { can } = usePermissions();
const canUpdate = can('appointment_settings', 'update');
const canUpdate = can('services', 'update');
const [urlState, setUrlState] = useUrlState({ search: '' });
const [editing, setEditing] = useState<{ open: boolean; category: CatalogCategory | null }>({ open: false, category: null });
+50 -5
View File
@@ -50,8 +50,27 @@ const previousSecretary = {
national_code: "9999999999",
};
function mockData(rows = [activeSecretary, previousSecretary]) {
/** فهرست بخش‌ها از GET /api/v1/permission-catalog می‌آید، نه از کد کامپوننت. */
const CATALOG = [
{
key: "appointments", label: "مدیریت نوبت‌ها", clinic_only: false,
actions: [{ key: "view", label: "مشاهده نوبت‌ها" }, { key: "create", label: "ایجاد نوبت" }],
},
{
key: "patients", label: "پرونده بیماران", clinic_only: false,
actions: [{ key: "view", label: "مشاهده بیماران" }],
},
{
key: "clinic_doctors", label: "مدیریت پزشکان کلینیک", clinic_only: true,
actions: [{ key: "view", label: "مشاهده پزشکان" }],
},
];
function mockData(rows = [activeSecretary, previousSecretary], catalog: unknown[] = CATALOG) {
get.mockImplementation((url: string) => {
if (url.includes("permission-catalog")) {
return Promise.resolve({ success: true, data: { version: 1, resources: catalog } });
}
if (url.includes("/secretaries/")) return Promise.resolve({ success: true, data: rows });
return Promise.resolve({ success: true, data: [] });
});
@@ -94,7 +113,7 @@ describe("MySecretariesPage", () => {
expect(await screen.findByText("هنوز منشی فعالی اضافه نشده است")).toBeInTheDocument();
});
it("opens the add modal with permission sections based on existing pages", async () => {
it("opens the add modal with permission sections from the catalog", async () => {
renderWithProviders(<MySecretariesPage />, { route: "/admin/my-secretaries" });
await screen.findAllByText("سارا احمدی");
@@ -102,9 +121,35 @@ describe("MySecretariesPage", () => {
expect(await screen.findByText("اضافه کردن منشی جدید")).toBeInTheDocument();
expect(screen.getByText("مجوزهای دسترسی")).toBeInTheDocument();
expect(screen.getByText("مدیریت نوبت‌ها")).toBeInTheDocument();
expect(await screen.findByText("مدیریت نوبت‌ها")).toBeInTheDocument();
expect(screen.getByText("پرونده بیماران")).toBeInTheDocument();
expect(screen.getByText("مدیریت پرداخت‌ها")).toBeInTheDocument();
expect(screen.getByText("مدیریت بیمه‌ها")).toBeInTheDocument();
});
/**
* همان تضمینِ «داینامیک بودن»: منبع تازه فقط به mock اضافه می‌شود و هیچ خطی از
* کد کامپوننت عوض نمی‌شود.
*/
it("shows a resource added to the catalog with no code change", async () => {
mockData(undefined, [
...CATALOG,
{ key: "brand_new_page", label: "صفحهٔ کاملاً تازه", clinic_only: false, actions: [{ key: "view", label: "مشاهده" }] },
]);
renderWithProviders(<MySecretariesPage />, { route: "/admin/my-secretaries" });
await screen.findAllByText("سارا احمدی");
fireEvent.click(screen.getByText("اضافه کردن منشی"));
expect(await screen.findByText("صفحهٔ کاملاً تازه")).toBeInTheDocument();
});
/** منبعِ clinic_only برای پزشکِ مستقل (primaryRole=doctor) نباید دیده شود. */
it("hides clinic-only resources for a standalone doctor", async () => {
renderWithProviders(<MySecretariesPage />, { route: "/admin/my-secretaries" });
await screen.findAllByText("سارا احمدی");
fireEvent.click(screen.getByText("اضافه کردن منشی"));
expect(await screen.findByText("مدیریت نوبت‌ها")).toBeInTheDocument();
expect(screen.queryByText("مدیریت پزشکان کلینیک")).not.toBeInTheDocument();
});
});
+27 -188
View File
@@ -11,6 +11,8 @@ import { formatDate, digitsOnly, IRAN_MOBILE_RE, IRAN_NATIONAL_CODE_RE } from ".
import { useSubscription } from "../hooks/useSubscription";
import { useAuthStore } from "../stores/authStore";
import type { Secretary, SecretaryPermissions } from "../types";
import { usePermissionCatalog, blankPermissions, alignPermissions } from "../hooks/usePermissionCatalog";
import type { CatalogResource } from "../hooks/usePermissionCatalog";
import Switch from '../components/ui/Switch';
// ── SVG icons (copied verbatim from clinic-pro-tauri) ───────────────────────
@@ -67,190 +69,18 @@ function Avatar({ name, size = 32 }: { name?: string; size?: number }) {
// ── Permission sections (based on existing clinicpro pages) ─────────────────
const EMPTY_PERMISSIONS: SecretaryPermissions = {
appointments: { view: false, create: false, cancel: false, update_status: false },
patients: { view: false, create: false, update: false, delete: false },
payments: { view: false, create: false, update: false, delete: false },
insurances: { view: false, create: false, update: false, delete: false },
addresses: { view: false, create: false, update: false, delete: false },
clinic_info: { view: false, update: false },
inventory: { view: false, create: false, update: false, delete: false },
tags: { view: false, create: false, update: false, delete: false },
services: { view: false, create: false, update: false, delete: false },
staff: { view: false, create: false, update: false, delete: false },
discounts: { view: false, create: false, update: false, delete: false },
sms: { view: false, create: false, update: false, delete: false },
appointment_settings: { view: false, update: false },
clinic_doctors: { view: false, create: false, update: false, delete: false },
subscription: { view: false, create: false },
};
type PermSection = keyof SecretaryPermissions;
const PERMISSION_SECTIONS: {
key: PermSection;
title: string;
/** فقط برای مالکِ کلینیک نمایش داده می‌شود (پزشک مستقل نه toggle نه منو). */
clinicOnly?: boolean;
items: { key: string; label: string }[];
}[] = [
{
key: "appointments",
title: "مدیریت نوبت‌ها",
items: [
{ key: "view", label: "مشاهده نوبت‌ها" },
{ key: "create", label: "ایجاد نوبت" },
{ key: "cancel", label: "لغو نوبت" },
{ key: "update_status", label: "تغییر وضعیت نوبت" },
],
},
{
key: "patients",
title: "پرونده بیماران",
items: [
{ key: "view", label: "مشاهده بیماران" },
{ key: "create", label: "ایجاد بیمار" },
{ key: "update", label: "ویرایش بیمار" },
{ key: "delete", label: "حذف بیمار" },
],
},
{
key: "payments",
title: "مدیریت پرداخت‌ها",
items: [
{ key: "view", label: "مشاهده پرداخت‌ها" },
{ key: "create", label: "ثبت پرداخت" },
{ key: "update", label: "ویرایش پرداخت" },
{ key: "delete", label: "حذف پرداخت" },
],
},
{
key: "insurances",
title: "مدیریت بیمه‌ها",
items: [
{ key: "view", label: "مشاهده بیمه‌ها" },
{ key: "create", label: "ایجاد بیمه" },
{ key: "update", label: "ویرایش بیمه" },
{ key: "delete", label: "حذف بیمه" },
],
},
{
key: "addresses",
title: "آدرس‌ها",
items: [
{ key: "view", label: "مشاهده آدرس‌ها" },
{ key: "create", label: "ایجاد آدرس" },
{ key: "update", label: "ویرایش آدرس" },
{ key: "delete", label: "حذف آدرس" },
],
},
{
key: "clinic_info",
title: "اطلاعات کلینیک",
items: [
{ key: "view", label: "مشاهده اطلاعات" },
{ key: "update", label: "ویرایش اطلاعات" },
],
},
{
key: "inventory",
title: "انبار",
items: [
{ key: "view", label: "مشاهده انبار" },
{ key: "create", label: "ایجاد کالا/بسته" },
{ key: "update", label: "ویرایش انبار" },
{ key: "delete", label: "حذف از انبار" },
],
},
{
key: "tags",
title: "تگ‌ها",
items: [
{ key: "view", label: "مشاهده تگ‌ها" },
{ key: "create", label: "ایجاد تگ" },
{ key: "update", label: "ویرایش تگ" },
{ key: "delete", label: "حذف تگ" },
],
},
{
key: "services",
title: "خدمات و تعرفه‌ها",
items: [
{ key: "view", label: "مشاهده خدمات" },
{ key: "create", label: "ایجاد خدمت" },
{ key: "update", label: "ویرایش خدمت" },
{ key: "delete", label: "حذف خدمت" },
],
},
{
key: "staff",
title: "پرسنل",
items: [
{ key: "view", label: "مشاهده پرسنل" },
{ key: "create", label: "افزودن پرسنل" },
{ key: "update", label: "ویرایش پرسنل" },
{ key: "delete", label: "حذف پرسنل" },
],
},
{
key: "discounts",
title: "تخفیف‌ها",
items: [
{ key: "view", label: "مشاهده تخفیف‌ها" },
{ key: "create", label: "ایجاد تخفیف" },
{ key: "update", label: "ویرایش تخفیف" },
{ key: "delete", label: "حذف تخفیف" },
],
},
{
key: "sms",
title: "پیامک‌ها",
items: [
{ key: "view", label: "مشاهده پیامک/کیف پول" },
{ key: "create", label: "شارژ/ارسال" },
{ key: "update", label: "ویرایش تنظیمات" },
{ key: "delete", label: "حذف" },
],
},
{
key: "appointment_settings",
title: "تنظیمات نوبت‌دهی",
items: [
{ key: "view", label: "مشاهده تنظیمات" },
{ key: "update", label: "ویرایش تنظیمات" },
],
},
{
key: "clinic_doctors",
title: "مدیریت پزشکان کلینیک",
clinicOnly: true,
items: [
{ key: "view", label: "مشاهده پزشکان" },
{ key: "create", label: "افزودن پزشک" },
{ key: "update", label: "ویرایش پزشک" },
{ key: "delete", label: "حذف پزشک" },
],
},
{
key: "subscription",
title: "خرید اشتراک",
items: [
{ key: "view", label: "مشاهده اشتراک" },
{ key: "create", label: "خرید/فعال‌سازی اشتراک" },
],
},
];
function PermissionAccordions({
permissions,
onChange,
disabled,
isClinic,
resources,
}: {
permissions: SecretaryPermissions;
onChange: (section: PermSection, item: string, value: boolean) => void;
onChange: (section: string, item: string, value: boolean) => void;
disabled?: boolean;
isClinic: boolean;
resources: CatalogResource[];
}) {
const [openKeys, setOpenKeys] = useState<Set<string>>(
new Set(["appointments", "patients"]),
@@ -264,8 +94,8 @@ function PermissionAccordions({
});
};
// منابع clinicOnly (مثل مدیریت پزشکان کلینیک) فقط برای مالکِ کلینیک دیده می‌شوند.
const sections = PERMISSION_SECTIONS.filter((s) => !s.clinicOnly || isClinic);
// منابع clinic_only (مثل مدیریت پزشکان کلینیک) فقط برای مالکِ کلینیک دیده می‌شوند.
const sections = resources.filter((r) => !r.clinic_only || isClinic);
return (
<div className="w-full">
@@ -275,7 +105,7 @@ function PermissionAccordions({
<div className="flex flex-col gap-[8px] w-full items-stretch">
{sections.map((section) => {
const open = openKeys.has(section.key);
const sectionPerm = permissions[section.key] as Record<string, boolean>;
const sectionPerm = permissions[section.key];
return (
<div
key={section.key}
@@ -287,14 +117,14 @@ function PermissionAccordions({
className="w-full flex items-center justify-between px-[16px] min-h-[64px] cursor-pointer"
>
<p className="text-[var(--text)] text-[14px] font-medium">
{section.title}
{section.label}
</p>
<ChevronDown open={open} />
</button>
{open && (
<div className="px-[16px] pt-[8px] pb-[16px]">
<div className="grid grid-cols-1 md:grid-cols-2 gap-x-[16px] gap-y-0">
{section.items.map((item) => (
{section.actions.map((item) => (
<label
key={item.key}
className="flex items-center gap-[11px] cursor-pointer py-[9px] min-h-[42px]"
@@ -403,21 +233,24 @@ function SecretaryModal({
onClose: () => void;
onSubmit: (form: FormState, doctorUuids: string[]) => void;
}) {
const catalog = usePermissionCatalog();
const [form, setForm] = useState<FormState>({
name: "",
family: "",
telephone: "",
national_code: "",
address: "",
permission: EMPTY_PERMISSIONS,
permission: {},
});
const [doctorUuids, setDoctorUuids] = useState<string[]>([]);
// انتخاب چند پزشک برای منشیِ کلینیک، هم در افزودن و هم در ویرایش
const showDoctorPicker = isClinic && mode !== "view";
// شکلِ فرم را کاتالوگ می‌دهد؛ تا نیامده مقداردهی نمی‌شود، وگرنه سوییچ‌ها از
// uncontrolled به controlled می‌پرند و مقدارِ ذخیره‌شده پاک می‌شود.
useEffect(() => {
if (!open) return;
if (!open || catalog.isLoading) return;
setDoctorUuids([]);
if ((mode === "edit" || mode === "view") && data) {
const secretary = data.primary;
@@ -429,7 +262,7 @@ function SecretaryModal({
telephone: secretary.mobile_number ?? "",
national_code: secretary.national_code ?? "",
address: secretary.address ?? "",
permission: { ...EMPTY_PERMISSIONS, ...(secretary.permissions ?? {}) },
permission: alignPermissions(secretary.permissions, catalog.resources),
});
} else {
setForm({
@@ -438,10 +271,10 @@ function SecretaryModal({
telephone: "",
national_code: "",
address: "",
permission: EMPTY_PERMISSIONS,
permission: blankPermissions(catalog.resources),
});
}
}, [open, mode, data]);
}, [open, mode, data, catalog.isLoading, catalog.resources]);
const disabled = mode === "view";
const title =
@@ -454,12 +287,12 @@ function SecretaryModal({
const setField = (field: keyof FormState, value: string) =>
setForm((prev) => ({ ...prev, [field]: value }));
const setPermission = (section: PermSection, item: string, value: boolean) =>
const setPermission = (section: string, item: string, value: boolean) =>
setForm((prev) => ({
...prev,
permission: {
...prev.permission,
[section]: { ...(prev.permission[section] as Record<string, boolean>), [item]: value },
[section]: { ...prev.permission[section], [item]: value },
},
}));
@@ -557,7 +390,13 @@ function SecretaryModal({
<DefaultTextField placeholder="آدرس" value={form.address} onChange={(v) => setField("address", v)} disabled={disabled} multiline rows={2} />
</div>
<PermissionAccordions permissions={form.permission} onChange={setPermission} disabled={disabled} isClinic={isClinic} />
{catalog.isLoading ? (
<p className="text-[var(--text-3)] text-[14px]">در حال بارگذاری فهرست دسترسیها...</p>
) : catalog.isError ? (
<p className="text-[var(--text-3)] text-[14px]">فهرست دسترسیها خوانده نشد. صفحه را دوباره باز کنید.</p>
) : (
<PermissionAccordions permissions={form.permission} onChange={setPermission} disabled={disabled} isClinic={isClinic} resources={catalog.resources} />
)}
</div>
</Modal>
);
+1 -1
View File
@@ -51,7 +51,7 @@ export default function ResourceDetailPage() {
const { offerings, save: saveServices } = useResourceServices(resourceUuid);
const { items: serviceOptions } = useAllServiceItems();
const { can } = usePermissions();
const canUpdate = can('appointment_settings', 'update');
const canUpdate = can('resources', 'update');
const [editOpen, setEditOpen] = useState(false);
const [confirmDeactivate, setConfirmDeactivate] = useState(false);
+1 -1
View File
@@ -22,7 +22,7 @@ export default function ResourcePoolsPage() {
const { pools, loading, create, update, remove, setMembers } = useResourcePools();
const { types } = useResourceTypes();
const { can } = usePermissions();
const canUpdate = can('appointment_settings', 'update');
const canUpdate = can('resources', 'update');
const [urlState, setUrlState] = useUrlState({ search: '' });
const [creating, setCreating] = useState(false);
+1 -1
View File
@@ -19,7 +19,7 @@ import ResourcesSubNav from '../components/resources/ResourcesSubNav';
export default function ResourceTypesPage() {
const { types, loading, create, update, remove } = useResourceTypes();
const { can } = usePermissions();
const canUpdate = can('appointment_settings', 'update');
const canUpdate = can('resources', 'update');
const [urlState, setUrlState] = useUrlState({ search: '' });
const [editing, setEditing] = useState<{ open: boolean; type: ResourceType | null }>({ open: false, type: null });
+1 -1
View File
@@ -35,7 +35,7 @@ export default function ResourcesPage() {
const { types } = useResourceTypes();
const { skills } = useSkills();
const { can } = usePermissions();
const canUpdate = can('appointment_settings', 'update');
const canUpdate = can('resources', 'update');
const { resources, loading, create } = useResources({
type_uuid: urlState.type || undefined,
+30 -170
View File
@@ -14,176 +14,30 @@ import Pagination from '../components/ui/Pagination';
import ConfirmDialog from '../components/ui/ConfirmDialog';
import Modal from '../components/ui/Modal';
import Switch from '../components/ui/Switch';
import { usePermissionCatalog, alignPermissions } from '../hooks/usePermissionCatalog';
import type { CatalogResource } from '../hooks/usePermissionCatalog';
const DEFAULT_PERMISSIONS: SecretaryPermissions = {
appointments: { view: true, create: false, cancel: false, update_status: false },
patients: { view: true, create: false, update: false, delete: false },
payments: { view: true, create: false, update: false, delete: false },
insurances: { view: true, create: false, update: false, delete: false },
addresses: { view: true, create: false, update: false, delete: false },
clinic_info: { view: true, update: false },
inventory: { view: false, create: false, update: false, delete: false },
tags: { view: false, create: false, update: false, delete: false },
services: { view: false, create: false, update: false, delete: false },
staff: { view: false, create: false, update: false, delete: false },
discounts: { view: false, create: false, update: false, delete: false },
sms: { view: false, create: false, update: false, delete: false },
appointment_settings: { view: false, update: false },
clinic_doctors: { view: false, create: false, update: false, delete: false },
subscription: { view: false, create: false },
};
type PermSection = keyof SecretaryPermissions;
const PERMISSION_LABELS: Record<PermSection, { label: string; actions: { key: string; label: string }[] }> = {
appointments: {
label: 'نوبت‌ها',
actions: [
{ key: 'view', label: 'مشاهده' },
{ key: 'create', label: 'ایجاد' },
{ key: 'cancel', label: 'لغو' },
{ key: 'update_status', label: 'تغییر وضعیت' },
],
},
patients: {
label: 'پرونده بیماران',
actions: [
{ key: 'view', label: 'مشاهده' },
{ key: 'create', label: 'ایجاد' },
{ key: 'update', label: 'ویرایش' },
{ key: 'delete', label: 'حذف' },
],
},
payments: {
label: 'پرداخت‌ها',
actions: [
{ key: 'view', label: 'مشاهده' },
{ key: 'create', label: 'ایجاد' },
{ key: 'update', label: 'ویرایش' },
{ key: 'delete', label: 'حذف' },
],
},
addresses: {
label: 'آدرس‌ها',
actions: [
{ key: 'view', label: 'مشاهده' },
{ key: 'create', label: 'ایجاد' },
{ key: 'update', label: 'ویرایش' },
{ key: 'delete', label: 'حذف' },
],
},
clinic_info: {
label: 'اطلاعات کلینیک',
actions: [
{ key: 'view', label: 'مشاهده' },
{ key: 'update', label: 'ویرایش' },
],
},
insurances: {
label: 'بیمه‌ها',
actions: [
{ key: 'view', label: 'مشاهده' },
{ key: 'create', label: 'ایجاد' },
{ key: 'update', label: 'ویرایش' },
{ key: 'delete', label: 'حذف' },
],
},
inventory: {
label: 'انبار',
actions: [
{ key: 'view', label: 'مشاهده' },
{ key: 'create', label: 'ایجاد' },
{ key: 'update', label: 'ویرایش' },
{ key: 'delete', label: 'حذف' },
],
},
tags: {
label: 'تگ‌ها',
actions: [
{ key: 'view', label: 'مشاهده' },
{ key: 'create', label: 'ایجاد' },
{ key: 'update', label: 'ویرایش' },
{ key: 'delete', label: 'حذف' },
],
},
services: {
label: 'خدمات و تعرفه‌ها',
actions: [
{ key: 'view', label: 'مشاهده' },
{ key: 'create', label: 'ایجاد' },
{ key: 'update', label: 'ویرایش' },
{ key: 'delete', label: 'حذف' },
],
},
staff: {
label: 'پرسنل',
actions: [
{ key: 'view', label: 'مشاهده' },
{ key: 'create', label: 'ایجاد' },
{ key: 'update', label: 'ویرایش' },
{ key: 'delete', label: 'حذف' },
],
},
discounts: {
label: 'تخفیف‌ها',
actions: [
{ key: 'view', label: 'مشاهده' },
{ key: 'create', label: 'ایجاد' },
{ key: 'update', label: 'ویرایش' },
{ key: 'delete', label: 'حذف' },
],
},
sms: {
label: 'پیامک‌ها',
actions: [
{ key: 'view', label: 'مشاهده' },
{ key: 'create', label: 'شارژ/ارسال' },
{ key: 'update', label: 'ویرایش' },
{ key: 'delete', label: 'حذف' },
],
},
appointment_settings: {
label: 'تنظیمات نوبت‌دهی',
actions: [
{ key: 'view', label: 'مشاهده' },
{ key: 'update', label: 'ویرایش' },
],
},
clinic_doctors: {
label: 'مدیریت پزشکان کلینیک',
actions: [
{ key: 'view', label: 'مشاهده' },
{ key: 'create', label: 'ایجاد' },
{ key: 'update', label: 'ویرایش' },
{ key: 'delete', label: 'حذف' },
],
},
subscription: {
label: 'خرید اشتراک',
actions: [
{ key: 'view', label: 'مشاهده' },
{ key: 'create', label: 'خرید' },
],
},
};
const ACTION_COLUMNS = ['view', 'create', 'update', 'delete', 'cancel', 'update_status'];
const ACTION_HEADERS = ['مشاهده', 'ایجاد', 'ویرایش', 'حذف', 'لغو', 'تغییر وضعیت'];
function PermissionsMatrix({
permissions,
onChange,
resources,
}: {
permissions: SecretaryPermissions;
onChange: (p: SecretaryPermissions) => void;
resources: CatalogResource[];
}) {
const toggle = (section: PermSection, action: string) => {
const current = (permissions[section] as Record<string, boolean>)[action];
const toggle = (section: string, action: string) => {
onChange({
...permissions,
[section]: { ...(permissions[section] as Record<string, boolean>), [action]: !current },
[section]: { ...permissions[section], [action]: !permissions[section]?.[action] },
});
};
const allActions = ['view', 'create', 'update', 'delete', 'cancel', 'update_status'];
const actionHeaders = ['مشاهده', 'ایجاد', 'ویرایش', 'حذف', 'لغو', 'تغییر وضعیت'];
const allActions = ACTION_COLUMNS;
const actionHeaders = ACTION_HEADERS;
return (
<div style={{ overflowX: 'auto' }}>
@@ -197,24 +51,22 @@ function PermissionsMatrix({
</tr>
</thead>
<tbody>
{(Object.keys(PERMISSION_LABELS) as PermSection[]).map((section) => {
const config = PERMISSION_LABELS[section];
const sectionPerms = permissions[section] as Record<string, boolean>;
{resources.map((resource) => {
const available = new Set(resource.actions.map((a) => a.key));
return (
<tr key={section}>
<td><b>{config.label}</b></td>
<tr key={resource.key}>
<td><b>{resource.label}</b></td>
{allActions.map((action) => {
const actionConfig = config.actions.find((a) => a.key === action);
if (!actionConfig) {
if (!available.has(action)) {
return <td key={action} style={{ textAlign: 'center', color: 'var(--border)' }}></td>;
}
return (
<td key={action}>
<div style={{ display: 'flex', justifyContent: 'center' }}>
<Switch
checked={sectionPerms[action] ?? false}
onChange={() => toggle(section, action)}
ariaLabel={`${section}${action}`}
checked={permissions[resource.key]?.[action] ?? false}
onChange={() => toggle(resource.key, action)}
ariaLabel={`${resource.label}${ACTION_HEADERS[ACTION_COLUMNS.indexOf(action)]}`}
/>
</div>
</td>
@@ -238,7 +90,8 @@ export default function SecretariesPage() {
const setPage = (p: number) => setUrlState({ page: String(p) });
const setSearch = (v: string) => setUrlState({ search: v, page: '1' });
const [editTarget, setEditTarget] = useState<Secretary | null>(null);
const [editPerms, setEditPerms] = useState<SecretaryPermissions>(DEFAULT_PERMISSIONS);
const [editPerms, setEditPerms] = useState<SecretaryPermissions>({});
const catalog = usePermissionCatalog();
const [deleteTarget, setDeleteTarget] = useState<Secretary | null>(null);
const limit = 15;
@@ -274,7 +127,8 @@ export default function SecretariesPage() {
const openEdit = (s: Secretary) => {
setEditTarget(s);
setEditPerms(s.permissions ?? DEFAULT_PERMISSIONS);
// شکل را کاتالوگ می‌دهد؛ منبعی که در JSONِ ذخیره‌شده نیست خاموش نمایش داده می‌شود.
setEditPerms(alignPermissions(s.permissions, catalog.resources));
};
const columns: Column<Secretary>[] = [
@@ -347,14 +201,20 @@ export default function SecretariesPage() {
<button onClick={() => setEditTarget(null)} className="btn ghost sm">لغو</button>
<button
onClick={() => editTarget && updatePermsMutation.mutate({ uuid: editTarget.uuid, permissions: editPerms })}
disabled={updatePermsMutation.isPending}
disabled={updatePermsMutation.isPending || catalog.isLoading}
className="btn primary sm">
{updatePermsMutation.isPending ? 'در حال ذخیره...' : 'ذخیره دسترسی‌ها'}
</button>
</>
}
>
<PermissionsMatrix permissions={editPerms} onChange={setEditPerms} />
{catalog.isLoading ? (
<p className="muted">در حال بارگذاری فهرست دسترسیها...</p>
) : catalog.isError ? (
<p className="muted">فهرست دسترسیها خوانده نشد. صفحه را دوباره باز کنید.</p>
) : (
<PermissionsMatrix permissions={editPerms} onChange={setEditPerms} resources={catalog.resources} />
)}
</Modal>
<ConfirmDialog
+1 -1
View File
@@ -21,7 +21,7 @@ import ResourcesSubNav from '../components/resources/ResourcesSubNav';
export default function SkillsPage() {
const { skills, loading, create, update, remove } = useSkills();
const { can } = usePermissions();
const canUpdate = can('appointment_settings', 'update');
const canUpdate = can('resources', 'update');
const [urlState, setUrlState] = useUrlState({ search: '' });
const [editing, setEditing] = useState<{ open: boolean; skill: Skill | null }>({ open: false, skill: null });