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>
121 lines
5.4 KiB
TypeScript
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>
|
|
);
|
|
}
|