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,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