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