feat(permissions): render both permission forms from the catalog, fix borrowed gates
The three hardcoded resource lists in the admin panel are gone. MySecretariesPage, SecretariesPage and DoctorPermissionsModal now render from GET /api/v1/permission-catalog, so a resource added to the backend registry shows up in all of them with no frontend change. Each has a test that proves exactly that by adding a resource to the mock and asserting it renders. SecretaryPermissions was an interface with a field per resource, which made "dynamic" impossible in TypeScript — every new resource would have been a compile error. It is now an open map. Only two files consumed it. The borrowed gates are corrected: - five resource pages moved off appointment_settings onto their own 'resources' - treatment-cases moved off appointments onto 'treatment' - service-categories moved onto 'services', which is what ServiceCatalogController actually manages (categories, item groups, service relations) — not resources TreatmentCaseController had no permission gate at all, only IS_AUTHENTICATED_FULLY, so any secretary could read and edit treatment cases. All seven of its actions are now gated on treatment view/update. ResourcePermissionTrait takes the resource from an overridable method instead of hardcoding appointment_settings. HolidayController overrides it back, since the holidays page really is appointment settings. The booking gate keeps its appointments.view fallback so a secretary who may book is not blocked by a resource-config permission. Defaults were picked to preserve today's effective access, so no role gains or loses a page from this move. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -14,176 +14,30 @@ import Pagination from '../components/ui/Pagination';
|
||||
import ConfirmDialog from '../components/ui/ConfirmDialog';
|
||||
import Modal from '../components/ui/Modal';
|
||||
import Switch from '../components/ui/Switch';
|
||||
import { usePermissionCatalog, alignPermissions } from '../hooks/usePermissionCatalog';
|
||||
import type { CatalogResource } from '../hooks/usePermissionCatalog';
|
||||
|
||||
const DEFAULT_PERMISSIONS: SecretaryPermissions = {
|
||||
appointments: { view: true, create: false, cancel: false, update_status: false },
|
||||
patients: { view: true, create: false, update: false, delete: false },
|
||||
payments: { view: true, create: false, update: false, delete: false },
|
||||
insurances: { view: true, create: false, update: false, delete: false },
|
||||
addresses: { view: true, create: false, update: false, delete: false },
|
||||
clinic_info: { view: true, update: false },
|
||||
inventory: { view: false, create: false, update: false, delete: false },
|
||||
tags: { view: false, create: false, update: false, delete: false },
|
||||
services: { view: false, create: false, update: false, delete: false },
|
||||
staff: { view: false, create: false, update: false, delete: false },
|
||||
discounts: { view: false, create: false, update: false, delete: false },
|
||||
sms: { view: false, create: false, update: false, delete: false },
|
||||
appointment_settings: { view: false, update: false },
|
||||
clinic_doctors: { view: false, create: false, update: false, delete: false },
|
||||
subscription: { view: false, create: false },
|
||||
};
|
||||
|
||||
type PermSection = keyof SecretaryPermissions;
|
||||
|
||||
const PERMISSION_LABELS: Record<PermSection, { label: string; actions: { key: string; label: string }[] }> = {
|
||||
appointments: {
|
||||
label: 'نوبتها',
|
||||
actions: [
|
||||
{ key: 'view', label: 'مشاهده' },
|
||||
{ key: 'create', label: 'ایجاد' },
|
||||
{ key: 'cancel', label: 'لغو' },
|
||||
{ key: 'update_status', label: 'تغییر وضعیت' },
|
||||
],
|
||||
},
|
||||
patients: {
|
||||
label: 'پرونده بیماران',
|
||||
actions: [
|
||||
{ key: 'view', label: 'مشاهده' },
|
||||
{ key: 'create', label: 'ایجاد' },
|
||||
{ key: 'update', label: 'ویرایش' },
|
||||
{ key: 'delete', label: 'حذف' },
|
||||
],
|
||||
},
|
||||
payments: {
|
||||
label: 'پرداختها',
|
||||
actions: [
|
||||
{ key: 'view', label: 'مشاهده' },
|
||||
{ key: 'create', label: 'ایجاد' },
|
||||
{ key: 'update', label: 'ویرایش' },
|
||||
{ key: 'delete', label: 'حذف' },
|
||||
],
|
||||
},
|
||||
addresses: {
|
||||
label: 'آدرسها',
|
||||
actions: [
|
||||
{ key: 'view', label: 'مشاهده' },
|
||||
{ key: 'create', label: 'ایجاد' },
|
||||
{ key: 'update', label: 'ویرایش' },
|
||||
{ key: 'delete', label: 'حذف' },
|
||||
],
|
||||
},
|
||||
clinic_info: {
|
||||
label: 'اطلاعات کلینیک',
|
||||
actions: [
|
||||
{ key: 'view', label: 'مشاهده' },
|
||||
{ key: 'update', label: 'ویرایش' },
|
||||
],
|
||||
},
|
||||
insurances: {
|
||||
label: 'بیمهها',
|
||||
actions: [
|
||||
{ key: 'view', label: 'مشاهده' },
|
||||
{ key: 'create', label: 'ایجاد' },
|
||||
{ key: 'update', label: 'ویرایش' },
|
||||
{ key: 'delete', label: 'حذف' },
|
||||
],
|
||||
},
|
||||
inventory: {
|
||||
label: 'انبار',
|
||||
actions: [
|
||||
{ key: 'view', label: 'مشاهده' },
|
||||
{ key: 'create', label: 'ایجاد' },
|
||||
{ key: 'update', label: 'ویرایش' },
|
||||
{ key: 'delete', label: 'حذف' },
|
||||
],
|
||||
},
|
||||
tags: {
|
||||
label: 'تگها',
|
||||
actions: [
|
||||
{ key: 'view', label: 'مشاهده' },
|
||||
{ key: 'create', label: 'ایجاد' },
|
||||
{ key: 'update', label: 'ویرایش' },
|
||||
{ key: 'delete', label: 'حذف' },
|
||||
],
|
||||
},
|
||||
services: {
|
||||
label: 'خدمات و تعرفهها',
|
||||
actions: [
|
||||
{ key: 'view', label: 'مشاهده' },
|
||||
{ key: 'create', label: 'ایجاد' },
|
||||
{ key: 'update', label: 'ویرایش' },
|
||||
{ key: 'delete', label: 'حذف' },
|
||||
],
|
||||
},
|
||||
staff: {
|
||||
label: 'پرسنل',
|
||||
actions: [
|
||||
{ key: 'view', label: 'مشاهده' },
|
||||
{ key: 'create', label: 'ایجاد' },
|
||||
{ key: 'update', label: 'ویرایش' },
|
||||
{ key: 'delete', label: 'حذف' },
|
||||
],
|
||||
},
|
||||
discounts: {
|
||||
label: 'تخفیفها',
|
||||
actions: [
|
||||
{ key: 'view', label: 'مشاهده' },
|
||||
{ key: 'create', label: 'ایجاد' },
|
||||
{ key: 'update', label: 'ویرایش' },
|
||||
{ key: 'delete', label: 'حذف' },
|
||||
],
|
||||
},
|
||||
sms: {
|
||||
label: 'پیامکها',
|
||||
actions: [
|
||||
{ key: 'view', label: 'مشاهده' },
|
||||
{ key: 'create', label: 'شارژ/ارسال' },
|
||||
{ key: 'update', label: 'ویرایش' },
|
||||
{ key: 'delete', label: 'حذف' },
|
||||
],
|
||||
},
|
||||
appointment_settings: {
|
||||
label: 'تنظیمات نوبتدهی',
|
||||
actions: [
|
||||
{ key: 'view', label: 'مشاهده' },
|
||||
{ key: 'update', label: 'ویرایش' },
|
||||
],
|
||||
},
|
||||
clinic_doctors: {
|
||||
label: 'مدیریت پزشکان کلینیک',
|
||||
actions: [
|
||||
{ key: 'view', label: 'مشاهده' },
|
||||
{ key: 'create', label: 'ایجاد' },
|
||||
{ key: 'update', label: 'ویرایش' },
|
||||
{ key: 'delete', label: 'حذف' },
|
||||
],
|
||||
},
|
||||
subscription: {
|
||||
label: 'خرید اشتراک',
|
||||
actions: [
|
||||
{ key: 'view', label: 'مشاهده' },
|
||||
{ key: 'create', label: 'خرید' },
|
||||
],
|
||||
},
|
||||
};
|
||||
const ACTION_COLUMNS = ['view', 'create', 'update', 'delete', 'cancel', 'update_status'];
|
||||
const ACTION_HEADERS = ['مشاهده', 'ایجاد', 'ویرایش', 'حذف', 'لغو', 'تغییر وضعیت'];
|
||||
|
||||
function PermissionsMatrix({
|
||||
permissions,
|
||||
onChange,
|
||||
resources,
|
||||
}: {
|
||||
permissions: SecretaryPermissions;
|
||||
onChange: (p: SecretaryPermissions) => void;
|
||||
resources: CatalogResource[];
|
||||
}) {
|
||||
const toggle = (section: PermSection, action: string) => {
|
||||
const current = (permissions[section] as Record<string, boolean>)[action];
|
||||
const toggle = (section: string, action: string) => {
|
||||
onChange({
|
||||
...permissions,
|
||||
[section]: { ...(permissions[section] as Record<string, boolean>), [action]: !current },
|
||||
[section]: { ...permissions[section], [action]: !permissions[section]?.[action] },
|
||||
});
|
||||
};
|
||||
|
||||
const allActions = ['view', 'create', 'update', 'delete', 'cancel', 'update_status'];
|
||||
const actionHeaders = ['مشاهده', 'ایجاد', 'ویرایش', 'حذف', 'لغو', 'تغییر وضعیت'];
|
||||
const allActions = ACTION_COLUMNS;
|
||||
const actionHeaders = ACTION_HEADERS;
|
||||
|
||||
return (
|
||||
<div style={{ overflowX: 'auto' }}>
|
||||
@@ -197,24 +51,22 @@ function PermissionsMatrix({
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{(Object.keys(PERMISSION_LABELS) as PermSection[]).map((section) => {
|
||||
const config = PERMISSION_LABELS[section];
|
||||
const sectionPerms = permissions[section] as Record<string, boolean>;
|
||||
{resources.map((resource) => {
|
||||
const available = new Set(resource.actions.map((a) => a.key));
|
||||
return (
|
||||
<tr key={section}>
|
||||
<td><b>{config.label}</b></td>
|
||||
<tr key={resource.key}>
|
||||
<td><b>{resource.label}</b></td>
|
||||
{allActions.map((action) => {
|
||||
const actionConfig = config.actions.find((a) => a.key === action);
|
||||
if (!actionConfig) {
|
||||
if (!available.has(action)) {
|
||||
return <td key={action} style={{ textAlign: 'center', color: 'var(--border)' }}>—</td>;
|
||||
}
|
||||
return (
|
||||
<td key={action}>
|
||||
<div style={{ display: 'flex', justifyContent: 'center' }}>
|
||||
<Switch
|
||||
checked={sectionPerms[action] ?? false}
|
||||
onChange={() => toggle(section, action)}
|
||||
ariaLabel={`${section} — ${action}`}
|
||||
checked={permissions[resource.key]?.[action] ?? false}
|
||||
onChange={() => toggle(resource.key, action)}
|
||||
ariaLabel={`${resource.label} — ${ACTION_HEADERS[ACTION_COLUMNS.indexOf(action)]}`}
|
||||
/>
|
||||
</div>
|
||||
</td>
|
||||
@@ -238,7 +90,8 @@ export default function SecretariesPage() {
|
||||
const setPage = (p: number) => setUrlState({ page: String(p) });
|
||||
const setSearch = (v: string) => setUrlState({ search: v, page: '1' });
|
||||
const [editTarget, setEditTarget] = useState<Secretary | null>(null);
|
||||
const [editPerms, setEditPerms] = useState<SecretaryPermissions>(DEFAULT_PERMISSIONS);
|
||||
const [editPerms, setEditPerms] = useState<SecretaryPermissions>({});
|
||||
const catalog = usePermissionCatalog();
|
||||
const [deleteTarget, setDeleteTarget] = useState<Secretary | null>(null);
|
||||
const limit = 15;
|
||||
|
||||
@@ -274,7 +127,8 @@ export default function SecretariesPage() {
|
||||
|
||||
const openEdit = (s: Secretary) => {
|
||||
setEditTarget(s);
|
||||
setEditPerms(s.permissions ?? DEFAULT_PERMISSIONS);
|
||||
// شکل را کاتالوگ میدهد؛ منبعی که در JSONِ ذخیرهشده نیست خاموش نمایش داده میشود.
|
||||
setEditPerms(alignPermissions(s.permissions, catalog.resources));
|
||||
};
|
||||
|
||||
const columns: Column<Secretary>[] = [
|
||||
@@ -347,14 +201,20 @@ export default function SecretariesPage() {
|
||||
<button onClick={() => setEditTarget(null)} className="btn ghost sm">لغو</button>
|
||||
<button
|
||||
onClick={() => editTarget && updatePermsMutation.mutate({ uuid: editTarget.uuid, permissions: editPerms })}
|
||||
disabled={updatePermsMutation.isPending}
|
||||
disabled={updatePermsMutation.isPending || catalog.isLoading}
|
||||
className="btn primary sm">
|
||||
{updatePermsMutation.isPending ? 'در حال ذخیره...' : 'ذخیره دسترسیها'}
|
||||
</button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<PermissionsMatrix permissions={editPerms} onChange={setEditPerms} />
|
||||
{catalog.isLoading ? (
|
||||
<p className="muted">در حال بارگذاری فهرست دسترسیها...</p>
|
||||
) : catalog.isError ? (
|
||||
<p className="muted">فهرست دسترسیها خوانده نشد. صفحه را دوباره باز کنید.</p>
|
||||
) : (
|
||||
<PermissionsMatrix permissions={editPerms} onChange={setEditPerms} resources={catalog.resources} />
|
||||
)}
|
||||
</Modal>
|
||||
|
||||
<ConfirmDialog
|
||||
|
||||
Reference in New Issue
Block a user