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>
159 lines
6.0 KiB
TypeScript
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>
|
|
);
|
|
}
|