refactor(admin): split the secretary form from its permissions into two modals
Adding a secretary meant deciding all 17 permission resources in the same dialog. The full-height capture showed the form running past 1300px with the save button below 16 accordions, and the dynamic registry makes that worse: every page added in future lengthens this one modal. The add/edit modal now carries only doctors, profile and address, and fits on screen with its footer visible. Permissions move to SecretaryPermissionsModal, reachable from a row action and opened automatically right after a successful add, since a new secretary starts on the role defaults and the owner usually wants to set them. Neither create nor update sends permissions any more — the backend seeds the role defaults on create, and the permissions modal owns the writes, fanning out over every link row so a secretary shared across doctors stays consistent. PermissionAccordions moves to components/ui as a shared component. Sections now start collapsed with a granted/total badge on each header, so the panel opens at a fixed height and still says which sections are on. Two design-system slips caught by re-screenshotting rather than by the audit: - a text button as a third row action pushed the name column out of the table, so the desktop row uses an icon with a title and the mobile card keeps the label - .btn.secondary is not defined in styles.css (variants are primary/ghost/soft/ danger/accent), so it renders as a bare .btn. Used ghost here. 25 other files have the same dead class; left alone as a separate sweep. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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<SecretaryPermissions>({});
|
||||
|
||||
// شکل را کاتالوگ میدهد؛ تا نیامده مقداردهی نمیشود وگرنه سوییچها از
|
||||
// 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 (
|
||||
<Modal
|
||||
open={open}
|
||||
size="lg"
|
||||
title={`دسترسیهای ${secretary?.user_name ?? 'منشی'}`}
|
||||
onClose={onClose}
|
||||
footer={
|
||||
<>
|
||||
<button type="button" className="btn ghost sm" onClick={onClose}>
|
||||
{readOnly ? 'بستن' : 'انصراف'}
|
||||
</button>
|
||||
{!readOnly && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn primary sm"
|
||||
disabled={save.isPending || catalog.isLoading}
|
||||
onClick={() => save.mutate()}
|
||||
>
|
||||
{save.isPending ? 'در حال ذخیره...' : 'ذخیره دسترسیها'}
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
}
|
||||
>
|
||||
{catalog.isLoading ? (
|
||||
<p className="muted">در حال بارگذاری فهرست دسترسیها...</p>
|
||||
) : catalog.isError ? (
|
||||
<p className="muted">فهرست دسترسیها خوانده نشد. مودال را دوباره باز کنید.</p>
|
||||
) : (
|
||||
<>
|
||||
<p className="muted" style={{ fontSize: 12.5, marginBottom: 14, lineHeight: 1.9 }}>
|
||||
هر بخش را باز کنید تا اجزایش را ببینید. عددِ کنار هر بخش میگوید چند مورد از
|
||||
آن روشن است.
|
||||
</p>
|
||||
<PermissionAccordions
|
||||
permissions={permissions}
|
||||
onChange={setPermission}
|
||||
disabled={readOnly}
|
||||
isClinic={isClinic}
|
||||
resources={catalog.resources}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -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 }) => (
|
||||
<svg
|
||||
width="20"
|
||||
height="20"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
style={{ transform: open ? 'rotate(180deg)' : 'none', transition: 'transform .18s var(--ease)' }}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
d="M6 9l6 6 6-6"
|
||||
stroke="var(--text-3)"
|
||||
strokeWidth="1.6"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
/**
|
||||
* فهرست آکاردئونیِ مجوزها — یک بخش به ازای هر منبعِ کاتالوگ.
|
||||
*
|
||||
* آکاردئون و نه جدول: تعداد منابع با هر صفحهٔ تازه رشد میکند و در ۳۹۰px یک جدولِ
|
||||
* ۶ ستونه اسکرول افقی میخواهد. همه بسته شروع میشوند تا ارتفاع اولیه ثابت بماند
|
||||
* و کاربر خودش سراغ بخشِ موردنظرش برود.
|
||||
*/
|
||||
export default function PermissionAccordions({
|
||||
permissions,
|
||||
onChange,
|
||||
disabled,
|
||||
isClinic,
|
||||
resources,
|
||||
}: {
|
||||
permissions: Record<string, Record<string, boolean>>;
|
||||
onChange: (section: string, item: string, value: boolean) => void;
|
||||
disabled?: boolean;
|
||||
isClinic: boolean;
|
||||
resources: CatalogResource[];
|
||||
}) {
|
||||
const [openKeys, setOpenKeys] = useState<Set<string>>(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 (
|
||||
<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];
|
||||
const granted = grantedCount(section);
|
||||
return (
|
||||
<div
|
||||
key={section.key}
|
||||
className="w-full border border-[var(--border-2)] rounded-[8px] overflow-hidden"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleOpen(section.key)}
|
||||
aria-expanded={open}
|
||||
className="w-full flex items-center justify-between px-[16px] min-h-[56px] cursor-pointer"
|
||||
>
|
||||
<span className="flex items-center gap-[10px]">
|
||||
<span className="text-[var(--text)] text-[14px] font-medium">
|
||||
{section.label}
|
||||
</span>
|
||||
<span
|
||||
className="text-[12px] rounded-[var(--r-pill)] px-[8px] py-[2px]"
|
||||
style={{
|
||||
background: granted > 0 ? 'var(--primary-soft)' : 'var(--surface-2)',
|
||||
color: granted > 0 ? 'var(--primary)' : 'var(--text-3)',
|
||||
}}
|
||||
>
|
||||
{formatNumber(granted)} از {formatNumber(section.actions.length)}
|
||||
</span>
|
||||
</span>
|
||||
<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.actions.map((item) => (
|
||||
<label
|
||||
key={item.key}
|
||||
className="flex items-center gap-[11px] cursor-pointer py-[9px] min-h-[42px]"
|
||||
>
|
||||
<Switch
|
||||
checked={sectionPerm?.[item.key] ?? false}
|
||||
disabled={disabled}
|
||||
onChange={(v) => onChange(section.key, item.key, v)}
|
||||
ariaLabel={`${section.label} — ${item.label}`}
|
||||
/>
|
||||
<span className="text-[var(--text-2)] text-[14px]">{item.label}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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(<MySecretariesPage />, { 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(<MySecretariesPage />, { 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(<MySecretariesPage />, { 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(<MySecretariesPage />, { 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(<MySecretariesPage />, { 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();
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
@@ -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 = () => (
|
||||
</svg>
|
||||
);
|
||||
|
||||
const ShieldIcon = () => (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 20 20" fill="none">
|
||||
<path d="M8.7415 1.8584L4.5832 3.42506C3.62486 3.78340 2.8415 4.91673 2.8415 5.93340V12.1084C2.8415 13.0917 3.4915 14.3834 4.28317 14.9751L7.86650 17.6501C9.04150 18.5334 10.9748 18.5334 12.1498 17.6501L15.7332 14.9751C16.5248 14.3834 17.1748 13.0917 17.1748 12.1084V5.93340C17.1748 4.90840 16.3915 3.77506 15.4332 3.41673L11.2748 1.8584C10.5665 1.60006 9.4332 1.60006 8.7415 1.8584Z" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" style={{ stroke: 'var(--text-2)' }}/>
|
||||
<path d="M7.5415 9.71673L8.99984 11.1751L12.4665 7.70840" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" style={{ stroke: 'var(--text-2)' }}/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
const ChevronDown = ({ open }: { open: boolean }) => (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
@@ -69,88 +75,6 @@ function Avatar({ name, size = 32 }: { name?: string; size?: number }) {
|
||||
|
||||
// ── Permission sections (based on existing clinicpro pages) ─────────────────
|
||||
|
||||
function PermissionAccordions({
|
||||
permissions,
|
||||
onChange,
|
||||
disabled,
|
||||
isClinic,
|
||||
resources,
|
||||
}: {
|
||||
permissions: SecretaryPermissions;
|
||||
onChange: (section: string, item: string, value: boolean) => void;
|
||||
disabled?: boolean;
|
||||
isClinic: boolean;
|
||||
resources: CatalogResource[];
|
||||
}) {
|
||||
const [openKeys, setOpenKeys] = useState<Set<string>>(
|
||||
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 (
|
||||
<div className="w-full">
|
||||
<p className="text-[var(--text)] text-[16px] font-bold mb-[16px]">
|
||||
مجوزهای دسترسی
|
||||
</p>
|
||||
<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];
|
||||
return (
|
||||
<div
|
||||
key={section.key}
|
||||
className="w-full border border-[var(--border-2)] rounded-[8px] overflow-hidden"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleOpen(section.key)}
|
||||
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.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.actions.map((item) => (
|
||||
<label
|
||||
key={item.key}
|
||||
className="flex items-center gap-[11px] cursor-pointer py-[9px] min-h-[42px]"
|
||||
>
|
||||
<Switch
|
||||
checked={sectionPerm?.[item.key] ?? false}
|
||||
disabled={disabled}
|
||||
onChange={(v) => onChange(section.key, item.key, v)}
|
||||
ariaLabel={item.label}
|
||||
/>
|
||||
<span className="text-[var(--text-2)] text-[14px]">
|
||||
{item.label}
|
||||
</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── 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<FormState>({
|
||||
name: "",
|
||||
family: "",
|
||||
telephone: "",
|
||||
national_code: "",
|
||||
address: "",
|
||||
permission: {},
|
||||
});
|
||||
const [doctorUuids, setDoctorUuids] = useState<string[]>([]);
|
||||
|
||||
// انتخاب چند پزشک برای منشیِ کلینیک، هم در افزودن و هم در ویرایش
|
||||
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({
|
||||
<DefaultTextField placeholder="آدرس" value={form.address} onChange={(v) => setField("address", v)} disabled={disabled} multiline rows={2} />
|
||||
</div>
|
||||
|
||||
{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} />
|
||||
{mode === "add" && (
|
||||
<p className="w-full text-right text-[var(--text-3)] text-[12.5px] leading-[1.9]">
|
||||
بعد از ذخیره، پنجرهٔ دسترسیها باز میشود تا مجوزهای این منشی را تنظیم کنید.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</Modal>
|
||||
@@ -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 (
|
||||
<div className="flex items-center justify-start gap-[16px]">
|
||||
<button onClick={onView} className="p-1 cursor-pointer" title="مشاهده">
|
||||
@@ -413,6 +323,11 @@ function RowButtons({ onView, onEdit }: { onView: () => void; onEdit: () => void
|
||||
<button onClick={onEdit} className="p-1 cursor-pointer" title="ویرایش">
|
||||
<EditIcon />
|
||||
</button>
|
||||
{/* آیکون و نه دکمهٔ متنی: ستون عملیات با یک دکمهٔ سوم متنی از عرض
|
||||
جدول بیرون میزد و «نام منشی» را میبرید. در کارت موبایل جا هست. */}
|
||||
<button onClick={onPermissions} className="p-1 cursor-pointer" title="دسترسیها" type="button">
|
||||
<ShieldIcon />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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({
|
||||
<span dir="ltr">{row.mobile_number}</span>
|
||||
</td>
|
||||
<td className={cellCls}>
|
||||
<RowButtons onView={() => onView(group)} onEdit={() => onEdit(group)} />
|
||||
<RowButtons onView={() => onView(group)} onEdit={() => onEdit(group)} onPermissions={() => onPermissions(group)} />
|
||||
</td>
|
||||
<td className={cellCls + " text-center"}>
|
||||
{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({
|
||||
>
|
||||
<EditIcon />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onPermissions(group)}
|
||||
className="btn ghost sm"
|
||||
>
|
||||
دسترسیها
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => onDeactivate(group)}
|
||||
@@ -731,6 +657,8 @@ function MySecretariesPageContent() {
|
||||
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [modalMode, setModalMode] = useState<ModalMode>("add");
|
||||
const [permOpen, setPermOpen] = useState(false);
|
||||
const [permTarget, setPermTarget] = useState<SecretaryGroup | null>(null);
|
||||
const [selected, setSelected] = useState<SecretaryGroup | null>(null);
|
||||
const [deactivateTarget, setDeactivateTarget] = useState<SecretaryGroup | null>(null);
|
||||
|
||||
@@ -783,7 +711,8 @@ function MySecretariesPageContent() {
|
||||
name: `${form.name} ${form.family}`.trim(),
|
||||
national_code: digitsOnly(form.national_code, 10) || null,
|
||||
address: form.address || null,
|
||||
permissions: { version: 1, resources: form.permission },
|
||||
// مجوز اینجا ارسال نمیشود: منشیِ تازه پیشفرضِ نقش را از بکاند
|
||||
// میگیرد و تنظیمش در پنجرهٔ جداگانهٔ دسترسیها انجام میشود.
|
||||
};
|
||||
return api.post<ApiResponse<any>>(
|
||||
"/api/v1/secretary",
|
||||
@@ -801,6 +730,16 @@ function MySecretariesPageContent() {
|
||||
}
|
||||
setModalOpen(false);
|
||||
invalidate();
|
||||
|
||||
// منشیِ تازه با پیشفرضِ نقش ساخته میشود؛ بلافاصله پنجرهٔ دسترسیها باز
|
||||
// میشود تا مالک همانجا تنظیمش کند، بدون اینکه فرمِ افزودن طولانی شود.
|
||||
// پاسخ دو شکل دارد: چندپزشکیِ کلینیک (`created`) و تکپزشکی (`data`).
|
||||
const payload = res?.data?.data ?? res?.data;
|
||||
const rows: Secretary[] = payload?.created ?? (payload?.uuid ? [payload] : []);
|
||||
if (rows.length > 0) {
|
||||
setPermTarget(groupBySecretary(rows)[0] ?? null);
|
||||
setPermOpen(true);
|
||||
}
|
||||
},
|
||||
onError: (e: any) => toast.error(e.message),
|
||||
});
|
||||
@@ -815,7 +754,7 @@ function MySecretariesPageContent() {
|
||||
name: `${form.name} ${form.family}`.trim(),
|
||||
national_code: digitsOnly(form.national_code, 10) || null,
|
||||
address: form.address || null,
|
||||
permissions: { version: 1, resources: form.permission },
|
||||
// مجوزها در SecretaryPermissionsModal ذخیره میشوند، نه اینجا.
|
||||
};
|
||||
// هر رابطهی پزشک-منشی جداگانه ذخیره میشود تا پروفایل در همه یکسان بماند
|
||||
await Promise.all(group.links.map((link) => api.patch(`/api/v1/secretary/${link.uuid}`, body)));
|
||||
@@ -856,6 +795,11 @@ function MySecretariesPageContent() {
|
||||
setModalOpen(true);
|
||||
};
|
||||
|
||||
const openPermissions = (g: SecretaryGroup) => {
|
||||
setPermTarget(g);
|
||||
setPermOpen(true);
|
||||
};
|
||||
|
||||
const openModal = (mode: ModalMode, g: SecretaryGroup) => {
|
||||
setModalMode(mode);
|
||||
setSelected(g);
|
||||
@@ -919,6 +863,7 @@ function MySecretariesPageContent() {
|
||||
onView={(s) => openModal("view", s)}
|
||||
onEdit={(s) => openModal("edit", s)}
|
||||
onDeactivate={(s) => setDeactivateTarget(s)}
|
||||
onPermissions={openPermissions}
|
||||
/>
|
||||
<SecretaryCards
|
||||
data={secretaries}
|
||||
@@ -926,6 +871,7 @@ function MySecretariesPageContent() {
|
||||
onView={(s) => openModal("view", s)}
|
||||
onEdit={(s) => openModal("edit", s)}
|
||||
onDeactivate={(s) => setDeactivateTarget(s)}
|
||||
onPermissions={openPermissions}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
@@ -949,6 +895,15 @@ function MySecretariesPageContent() {
|
||||
onSubmit={handleModalSubmit}
|
||||
/>
|
||||
|
||||
<SecretaryPermissionsModal
|
||||
open={permOpen}
|
||||
secretary={permTarget?.primary ?? null}
|
||||
links={permTarget?.links.map((l) => l.uuid) ?? []}
|
||||
isClinic={isClinic}
|
||||
onClose={() => setPermOpen(false)}
|
||||
onSaved={invalidate}
|
||||
/>
|
||||
|
||||
<ConfirmDialog
|
||||
open={!!deactivateTarget}
|
||||
title={deactivateTarget?.primary.is_active ? "لغو همکاری با منشی" : "فعالسازی منشی"}
|
||||
|
||||
Reference in New Issue
Block a user