Files
clinicpro/assets/admin/components/ui/DoctorPermissionsModal.tsx
T
hamedandClaude Opus 5 ddd5f8f75a 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>
2026-08-07 18:11:05 +03:30

159 lines
6.0 KiB
TypeScript

import { useEffect, useState } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { toast } from 'sonner';
import { api } from '../../lib/api';
import type { ApiResponse } from '../../lib/api';
import Modal from './Modal';
import Switch from './Switch';
import { usePermissionCatalog } from '../../hooks/usePermissionCatalog';
/** envelope کامل — همان چیزی که بک‌اند برمی‌گرداند، بدون flatten. */
export interface PermissionEnvelope {
version: number;
resources: Record<string, Record<string, boolean>>;
}
export interface ClinicDoctorPermissionPayload {
uuid: string;
clinic_uuid: string;
doctor_uuid: string;
doctor_name: string;
active: boolean;
permissions: PermissionEnvelope;
}
/** ستون‌های ثابتِ جدول؛ هر منبع فقط ستون‌هایی را پر می‌کند که کاتالوگ برایش داده. */
const ACTION_COLUMNS = ['view', 'create', 'update', 'delete', 'cancel', 'update_status'];
const ACTION_HEADERS = ['مشاهده', 'ایجاد', 'ویرایش', 'حذف', 'لغو', 'تغییر وضعیت'];
export default function DoctorPermissionsModal({ clinicUuid, doctorUuid, doctorName, onClose }: {
clinicUuid: string;
doctorUuid: string;
doctorName: string;
onClose: () => void;
}) {
const qc = useQueryClient();
const catalog = usePermissionCatalog();
const [resources, setResources] = useState<PermissionEnvelope['resources']>({});
const [active, setActive] = useState(true);
const permQ = useQuery({
queryKey: ['clinic-doctor-permissions', clinicUuid, doctorUuid],
queryFn: () => api.get<ApiResponse<ClinicDoctorPermissionPayload>>(
`/api/v1/admin/clinic/${clinicUuid}/doctor/${doctorUuid}/permissions`,
),
});
useEffect(() => {
const payload = permQ.data?.data;
if (!payload) return;
setResources(payload.permissions?.resources ?? {});
setActive(payload.active);
}, [permQ.data]);
const saveMut = useMutation({
mutationFn: () => api.patch<ApiResponse<ClinicDoctorPermissionPayload>>(
`/api/v1/admin/clinic/${clinicUuid}/doctor/${doctorUuid}/permissions`,
{ permissions: { resources }, active },
),
onSuccess: () => {
toast.success('دسترسی‌های پزشک ذخیره شد');
qc.invalidateQueries({ queryKey: ['clinic-doctor-permissions', clinicUuid, doctorUuid] });
qc.invalidateQueries({ queryKey: ['clinic-doctors', clinicUuid] });
onClose();
},
onError: (e: Error) => toast.error(e.message),
});
const toggle = (resource: string, action: string) => {
setResources(prev => ({
...prev,
[resource]: { ...prev[resource], [action]: !prev[resource]?.[action] },
}));
};
return (
<Modal
open
size="lg"
title={`دسترسی‌های ${doctorName}`}
onClose={onClose}
footer={
<>
<button className="btn ghost sm" onClick={onClose}>انصراف</button>
<button
className="btn primary sm"
disabled={saveMut.isPending || permQ.isLoading || catalog.isLoading}
onClick={() => saveMut.mutate()}
>
ذخیره
</button>
</>
}
>
{permQ.isLoading || catalog.isLoading ? (
<p className="muted">در حال بارگذاری...</p>
) : catalog.isError ? (
<p className="muted">فهرست دسترسی‌ها خوانده نشد. صفحه را دوباره باز کنید.</p>
) : (
<>
<div style={{ marginBottom: 14 }}>
<Switch
checked={active}
onChange={setActive}
label="دسترسی این پزشک به کلینیک فعال باشد"
hint="خاموش‌کردنش کل جدول زیر را بی‌اثر می‌کند."
/>
</div>
<div style={{ overflowX: 'auto' }}>
<table className="t">
<thead>
<tr>
<th>بخش</th>
{ACTION_HEADERS.map(h => (
<th key={h} style={{ textAlign: 'center', fontSize: 12 }}>{h}</th>
))}
</tr>
</thead>
<tbody>
{catalog.resources.map(resource => {
const available = new Set(resource.actions.map(a => a.key));
return (
<tr key={resource.key}>
<td><b>{resource.label}</b></td>
{ACTION_COLUMNS.map(action => {
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
disabled={!active}
checked={resources[resource.key]?.[action] ?? false}
onChange={() => toggle(resource.key, action)}
ariaLabel={`${resource.label}${ACTION_HEADERS[ACTION_COLUMNS.indexOf(action)]}`}
/>
</div>
</td>
);
})}
</tr>
);
})}
</tbody>
</table>
</div>
<p className="muted" style={{ fontSize: 12, marginTop: 12 }}>
این دسترسی‌ها فقط داخل همین کلینیک اعمال می‌شوند؛ مطب شخصی پزشک تحت تأثیر قرار نمی‌گیرد.
</p>
</>
)}
</Modal>
);
}