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:
@@ -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>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user