diff --git a/assets/admin/components/SecretaryPermissionsModal.tsx b/assets/admin/components/SecretaryPermissionsModal.tsx new file mode 100644 index 00000000..4bc9121c --- /dev/null +++ b/assets/admin/components/SecretaryPermissionsModal.tsx @@ -0,0 +1,116 @@ +import { useEffect, useState } from 'react'; +import { useMutation } from '@tanstack/react-query'; +import { toast } from 'sonner'; +import { api } from '../lib/api'; +import Modal from './ui/Modal'; +import PermissionAccordions from './ui/PermissionAccordions'; +import { usePermissionCatalog, alignPermissions } from '../hooks/usePermissionCatalog'; +import type { Secretary, SecretaryPermissions } from '../types'; + +/** + * دسترسی‌های یک منشی — جدا از فرمِ پروفایل. + * + * تا پیش از این هر دو در یک مودال بودند و افزودنِ یک منشی یعنی تصمیم‌گیری دربارهٔ + * همهٔ منابع در همان لحظه. با رجیستریِ داینامیک تعداد منابع با هر صفحهٔ تازه بیشتر + * می‌شود، پس آن مودال ذاتاً بلندتر می‌شد. + * + * ذخیره روی **همهٔ** ردیف‌های رابطه اجرا می‌شود: یک منشی به ازای هر پزشک یک ردیف + * دارد و مجوزها باید در همه یکسان بمانند — همان قاعده‌ای که ویرایش پروفایل دارد. + */ +export default function SecretaryPermissionsModal({ + open, + secretary, + links, + isClinic, + readOnly, + onClose, + onSaved, +}: { + open: boolean; + secretary: Secretary | null; + /** uuid هر رابطهٔ پزشک-منشی. */ + links: string[]; + isClinic: boolean; + readOnly?: boolean; + onClose: () => void; + onSaved?: () => void; +}) { + const catalog = usePermissionCatalog(); + const [permissions, setPermissions] = useState({}); + + // شکل را کاتالوگ می‌دهد؛ تا نیامده مقداردهی نمی‌شود وگرنه سوییچ‌ها از + // uncontrolled به controlled می‌پرند و مقدارِ ذخیره‌شده پاک می‌شود. + useEffect(() => { + if (!open || catalog.isLoading) return; + setPermissions(alignPermissions(secretary?.permissions, catalog.resources)); + }, [open, secretary, catalog.isLoading, catalog.resources]); + + const save = useMutation({ + mutationFn: () => + Promise.all( + links.map((uuid) => + api.patch(`/api/v1/secretary/${uuid}`, { + permissions: { version: 1, resources: permissions }, + }), + ), + ), + onSuccess: () => { + toast.success('دسترسی‌های منشی ذخیره شد'); + onSaved?.(); + onClose(); + }, + onError: (e: Error) => toast.error(e.message), + }); + + const setPermission = (section: string, item: string, value: boolean) => + setPermissions((prev) => ({ + ...prev, + [section]: { ...prev[section], [item]: value }, + })); + + return ( + + + {!readOnly && ( + + )} + + } + > + {catalog.isLoading ? ( +

در حال بارگذاری فهرست دسترسی‌ها...

+ ) : catalog.isError ? ( +

فهرست دسترسی‌ها خوانده نشد. مودال را دوباره باز کنید.

+ ) : ( + <> +

+ هر بخش را باز کنید تا اجزایش را ببینید. عددِ کنار هر بخش می‌گوید چند مورد از + آن روشن است. +

+ + + )} +
+ ); +} diff --git a/assets/admin/components/ui/PermissionAccordions.tsx b/assets/admin/components/ui/PermissionAccordions.tsx new file mode 100644 index 00000000..40e1f70c --- /dev/null +++ b/assets/admin/components/ui/PermissionAccordions.tsx @@ -0,0 +1,120 @@ +import { useState } from 'react'; +import Switch from './Switch'; +import { formatNumber } from '../../lib/utils'; +import type { CatalogResource } from '../../hooks/usePermissionCatalog'; + +const ChevronDown = ({ open }: { open: boolean }) => ( + +); + +/** + * فهرست آکاردئونیِ مجوزها — یک بخش به ازای هر منبعِ کاتالوگ. + * + * آکاردئون و نه جدول: تعداد منابع با هر صفحهٔ تازه رشد می‌کند و در ۳۹۰px یک جدولِ + * ۶ ستونه اسکرول افقی می‌خواهد. همه بسته شروع می‌شوند تا ارتفاع اولیه ثابت بماند + * و کاربر خودش سراغ بخشِ موردنظرش برود. + */ +export default function PermissionAccordions({ + permissions, + onChange, + disabled, + isClinic, + resources, +}: { + permissions: Record>; + onChange: (section: string, item: string, value: boolean) => void; + disabled?: boolean; + isClinic: boolean; + resources: CatalogResource[]; +}) { + const [openKeys, setOpenKeys] = useState>(new Set()); + + const toggleOpen = (key: string) => { + setOpenKeys((prev) => { + const next = new Set(prev); + next.has(key) ? next.delete(key) : next.add(key); + return next; + }); + }; + + // منابع clinic_only (مثل مدیریت پزشکان کلینیک) فقط برای مالکِ کلینیک دیده می‌شوند. + const sections = resources.filter((r) => !r.clinic_only || isClinic); + + /** شمارِ روشن‌ها روی هدر: بدون بازکردن هم معلوم است این بخش چه وضعی دارد. */ + const grantedCount = (section: CatalogResource) => + section.actions.filter((a) => permissions[section.key]?.[a.key]).length; + + return ( +
+ {sections.map((section) => { + const open = openKeys.has(section.key); + const sectionPerm = permissions[section.key]; + const granted = grantedCount(section); + return ( +
+ + {open && ( +
+
+ {section.actions.map((item) => ( + + ))} +
+
+ )} +
+ ); + })} +
+ ); +} diff --git a/assets/admin/pages/MySecretariesPage.test.tsx b/assets/admin/pages/MySecretariesPage.test.tsx index ee849711..4a723fa3 100644 --- a/assets/admin/pages/MySecretariesPage.test.tsx +++ b/assets/admin/pages/MySecretariesPage.test.tsx @@ -113,14 +113,28 @@ describe("MySecretariesPage", () => { expect(await screen.findByText("هنوز منشی فعالی اضافه نشده است")).toBeInTheDocument(); }); - it("opens the add modal with permission sections from the catalog", async () => { + // ── جداسازی فرمِ منشی از دسترسی‌ها ────────────────────────────────────── + // مودالِ افزودن دیگر مجوز ندارد؛ مجوزها مودالِ خودشان را دارند تا افزودنِ یک + // منشی به تصمیم‌گیری دربارهٔ همهٔ منابع گره نخورد. + + it("keeps permissions out of the add form", async () => { renderWithProviders(, { route: "/admin/my-secretaries" }); await screen.findAllByText("سارا احمدی"); fireEvent.click(screen.getByText("اضافه کردن منشی")); expect(await screen.findByText("اضافه کردن منشی جدید")).toBeInTheDocument(); - expect(screen.getByText("مجوزهای دسترسی")).toBeInTheDocument(); + expect(screen.queryByText("مدیریت نوبت‌ها")).not.toBeInTheDocument(); + expect(screen.queryByText("پرونده بیماران")).not.toBeInTheDocument(); + }); + + it("opens a separate permissions modal from the row action", async () => { + renderWithProviders(, { route: "/admin/my-secretaries" }); + await screen.findAllByText("سارا احمدی"); + + fireEvent.click(screen.getAllByRole("button", { name: "دسترسی‌ها" })[0]); + + expect(await screen.findByText("دسترسی‌های سارا احمدی")).toBeInTheDocument(); expect(await screen.findByText("مدیریت نوبت‌ها")).toBeInTheDocument(); expect(screen.getByText("پرونده بیماران")).toBeInTheDocument(); }); @@ -137,7 +151,7 @@ describe("MySecretariesPage", () => { renderWithProviders(, { route: "/admin/my-secretaries" }); await screen.findAllByText("سارا احمدی"); - fireEvent.click(screen.getByText("اضافه کردن منشی")); + fireEvent.click(screen.getAllByRole("button", { name: "دسترسی‌ها" })[0]); expect(await screen.findByText("صفحهٔ کاملاً تازه")).toBeInTheDocument(); }); @@ -147,9 +161,27 @@ describe("MySecretariesPage", () => { renderWithProviders(, { route: "/admin/my-secretaries" }); await screen.findAllByText("سارا احمدی"); - fireEvent.click(screen.getByText("اضافه کردن منشی")); + fireEvent.click(screen.getAllByRole("button", { name: "دسترسی‌ها" })[0]); expect(await screen.findByText("مدیریت نوبت‌ها")).toBeInTheDocument(); expect(screen.queryByText("مدیریت پزشکان کلینیک")).not.toBeInTheDocument(); }); + + /** آکاردئون‌ها بسته باز می‌شوند و شمارِ روشن‌ها روی هدر دیده می‌شود. */ + it("starts every section collapsed with a granted counter", async () => { + renderWithProviders(, { route: "/admin/my-secretaries" }); + await screen.findAllByText("سارا احمدی"); + + fireEvent.click(screen.getAllByRole("button", { name: "دسترسی‌ها" })[0]); + await screen.findByText("مدیریت نوبت‌ها"); + + // appointments.view=true در fullPerms → «۱ از ۲» با ارقام فارسی + expect(screen.getByText("۱ از ۲")).toBeInTheDocument(); + // بسته است، پس سوییچِ داخلش رندر نشده + expect(screen.queryByLabelText("مدیریت نوبت‌ها — مشاهده نوبت‌ها")).not.toBeInTheDocument(); + + fireEvent.click(screen.getByText("مدیریت نوبت‌ها")); + expect(await screen.findByLabelText("مدیریت نوبت‌ها — مشاهده نوبت‌ها")).toBeInTheDocument(); + }); + }); diff --git a/assets/admin/pages/MySecretariesPage.tsx b/assets/admin/pages/MySecretariesPage.tsx index 20cfe917..e203eff6 100644 --- a/assets/admin/pages/MySecretariesPage.tsx +++ b/assets/admin/pages/MySecretariesPage.tsx @@ -11,8 +11,7 @@ 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 SecretaryPermissionsModal from "../components/SecretaryPermissionsModal"; import Switch from '../components/ui/Switch'; // ── SVG icons (copied verbatim from clinic-pro-tauri) ─────────────────────── @@ -39,6 +38,13 @@ const EditIcon = () => ( ); +const ShieldIcon = () => ( + + + + +); + const ChevronDown = ({ open }: { open: boolean }) => ( void; - disabled?: boolean; - isClinic: boolean; - resources: CatalogResource[]; -}) { - const [openKeys, setOpenKeys] = useState>( - new Set(["appointments", "patients"]), - ); - - const toggleOpen = (key: string) => { - setOpenKeys((prev) => { - const next = new Set(prev); - next.has(key) ? next.delete(key) : next.add(key); - return next; - }); - }; - - // منابع clinic_only (مثل مدیریت پزشکان کلینیک) فقط برای مالکِ کلینیک دیده می‌شوند. - const sections = resources.filter((r) => !r.clinic_only || isClinic); - - return ( -
-

- مجوزهای دسترسی -

-
- {sections.map((section) => { - const open = openKeys.has(section.key); - const sectionPerm = permissions[section.key]; - return ( -
- - {open && ( -
-
- {section.actions.map((item) => ( - - ))} -
-
- )} -
- ); - })} -
-
- ); -} - // ── Text field (tauri DefaultTextField look) ──────────────────────────────── function DefaultTextField({ @@ -211,7 +135,6 @@ interface FormState { telephone: string; national_code: string; address: string; - permission: SecretaryPermissions; } function SecretaryModal({ @@ -233,24 +156,20 @@ function SecretaryModal({ onClose: () => void; onSubmit: (form: FormState, doctorUuids: string[]) => void; }) { - const catalog = usePermissionCatalog(); const [form, setForm] = useState({ name: "", family: "", telephone: "", national_code: "", address: "", - permission: {}, }); const [doctorUuids, setDoctorUuids] = useState([]); // انتخاب چند پزشک برای منشیِ کلینیک، هم در افزودن و هم در ویرایش const showDoctorPicker = isClinic && mode !== "view"; - // شکلِ فرم را کاتالوگ می‌دهد؛ تا نیامده مقداردهی نمی‌شود، وگرنه سوییچ‌ها از - // uncontrolled به controlled می‌پرند و مقدارِ ذخیره‌شده پاک می‌شود. useEffect(() => { - if (!open || catalog.isLoading) return; + if (!open) return; setDoctorUuids([]); if ((mode === "edit" || mode === "view") && data) { const secretary = data.primary; @@ -262,7 +181,6 @@ function SecretaryModal({ telephone: secretary.mobile_number ?? "", national_code: secretary.national_code ?? "", address: secretary.address ?? "", - permission: alignPermissions(secretary.permissions, catalog.resources), }); } else { setForm({ @@ -271,10 +189,9 @@ function SecretaryModal({ telephone: "", national_code: "", address: "", - permission: blankPermissions(catalog.resources), }); } - }, [open, mode, data, catalog.isLoading, catalog.resources]); + }, [open, mode, data]); const disabled = mode === "view"; const title = @@ -287,15 +204,6 @@ function SecretaryModal({ const setField = (field: keyof FormState, value: string) => setForm((prev) => ({ ...prev, [field]: value })); - const setPermission = (section: string, item: string, value: boolean) => - setForm((prev) => ({ - ...prev, - permission: { - ...prev.permission, - [section]: { ...prev.permission[section], [item]: value }, - }, - })); - const handleSubmit = () => { if (mode === "view") { onClose(); @@ -390,12 +298,10 @@ function SecretaryModal({ setField("address", v)} disabled={disabled} multiline rows={2} /> - {catalog.isLoading ? ( -

در حال بارگذاری فهرست دسترسی‌ها...

- ) : catalog.isError ? ( -

فهرست دسترسی‌ها خوانده نشد. صفحه را دوباره باز کنید.

- ) : ( - + {mode === "add" && ( +

+ بعد از ذخیره، پنجرهٔ دسترسی‌ها باز می‌شود تا مجوزهای این منشی را تنظیم کنید. +

)} @@ -404,7 +310,11 @@ function SecretaryModal({ // ── Row buttons (view + edit) ─────────────────────────────────────────────── -function RowButtons({ onView, onEdit }: { onView: () => void; onEdit: () => void }) { +function RowButtons({ + onView, + onEdit, + onPermissions, +}: { onView: () => void; onEdit: () => void; onPermissions: () => void }) { return (
+ {/* آیکون و نه دکمهٔ متنی: ستون عملیات با یک دکمهٔ سوم متنی از عرض + جدول بیرون می‌زد و «نام منشی» را می‌برید. در کارت موبایل جا هست. */} +
); } @@ -463,12 +378,14 @@ function SecretaryTable({ onView, onEdit, onDeactivate, + onPermissions, showDoctor, }: { data: SecretaryGroup[]; onView: (g: SecretaryGroup) => void; onEdit: (g: SecretaryGroup) => void; onDeactivate: (g: SecretaryGroup) => void; + onPermissions: (g: SecretaryGroup) => void; showDoctor: boolean; }) { const headCls = @@ -512,7 +429,7 @@ function SecretaryTable({ {row.mobile_number} - onView(group)} onEdit={() => onEdit(group)} /> + onView(group)} onEdit={() => onEdit(group)} onPermissions={() => onPermissions(group)} /> {row.is_active ? ( @@ -548,12 +465,14 @@ function SecretaryCards({ onView, onEdit, onDeactivate, + onPermissions, showDoctor, }: { data: SecretaryGroup[]; onView: (g: SecretaryGroup) => void; onEdit: (g: SecretaryGroup) => void; onDeactivate: (g: SecretaryGroup) => void; + onPermissions: (g: SecretaryGroup) => void; showDoctor: boolean; }) { return ( @@ -609,6 +528,13 @@ function SecretaryCards({ > +