Files
hamedandClaude Opus 5 7c3407b0b3 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>
2026-08-07 19:55:32 +03:30

121 lines
5.4 KiB
TypeScript

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>
);
}